From 9f6b59d7581eba1e337b584d5a4f5d00bc19493c Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 14 Aug 2026 16:01:06 +0200 Subject: [PATCH 01/13] feat: migrate navigation to Navigation 3 --- app/build.gradle.kts | 18 +- app/proguard-rules.pro | 2 - .../android/ui/WireActivityNavigatorTest.kt | 122 -- .../com/wire/android/WireApplication.kt | 5 + .../kotlin/com/wire/android/di/AppModule.kt | 5 - .../com/wire/android/di/CoreLogicModule.kt | 35 - .../di/metro/AppSessionViewModelGraph.kt | 78 +- .../AuthenticationMetroViewModelBindings.kt | 120 ++ .../android/di/metro/WireApplicationGraph.kt | 49 +- .../di/metro/WireMetroViewModelBindings.kt | 740 ++------- .../di/metro/WireMetroViewModelFactory.kt | 56 +- .../android/feature/AccountSwitchUseCase.kt | 15 - .../wire/android/navigation/FolderNavArgs.kt | 27 - .../android/navigation/HomeDestination.kt | 21 - .../wire/android/navigation/MainNavHost.kt | 184 --- .../android/navigation/NavigationUtils.kt | 148 -- .../com/wire/android/navigation/Navigator.kt | 79 - .../android/navigation/OtherDestinations.kt | 133 -- .../navigation/TabletDialogRoutePolicy.kt | 70 - .../android/navigation/TabletStyleHelper.kt | 27 - .../android/navigation/WireNavHostEngine.kt | 158 -- .../android/navigation/WireRootNavGraph.kt | 33 - .../annotation/app/AppDestinations.kt | 124 -- .../auth/AuthenticationLegacyMappers.kt | 227 +++ .../auth/AuthenticationNavigation3Entries.kt | 417 +++++ .../auth/AuthenticationNavigation3Router.kt | 314 ++++ .../routes/auth/AuthenticationRoutes.kt | 247 +++ .../routes/auth/CreateAccountLegacyMappers.kt | 111 ++ .../routes/auth/CreateAccountNavigation3.kt | 224 +++ .../auth/CreateAccountNavigation3Entries.kt | 299 ++++ .../routes/media/MediaNavigation3Entries.kt | 211 +++ .../routes/media/MediaNavigation3Mappers.kt | 54 + .../navigation/routes/media/MediaRoutes.kt | 145 ++ .../utility/UtilityNavigation3Entries.kt | 88 ++ .../routes/utility/UtilityRoutes.kt | 82 + .../runtime/MetroWireEntryEnvironment.kt | 177 +++ .../runtime/Navigation3RouteObservation.kt | 41 + .../runtime/SessionGraphStoreViewModel.kt | 333 ++++ .../runtime/WireActivityGraphContext.kt | 84 + .../runtime/WireActivityIntentCoordinator.kt | 145 ++ .../runtime/WireNavigation3ActivityEffects.kt | 196 +++ .../runtime/WireNavigation3ActivityPolicy.kt | 49 + .../runtime/WireNavigation3Contributions.kt | 168 ++ .../WireNavigation3ProductionActions.kt | 656 ++++++++ .../runtime/WireNavigation3ProductionHost.kt | 72 + .../WireNavigation3ResponsivePresentation.kt | 68 + .../WireNavigation3SwitchAccountActions.kt | 66 + .../runtime/WireNavigationDiagnostics.kt | 144 ++ .../runtime/WireRouteScopeResolver.kt | 71 + .../runtime/WireSessionNavigationPolicy.kt | 82 + .../navigation/runtime/startup/HomeRoute.kt | 42 + .../startup/WireInitialRouteResolver.kt | 70 + .../com/wire/android/ui/AppLockActivity.kt | 141 +- .../com/wire/android/ui/MiscViewModelGraph.kt | 45 +- .../com/wire/android/ui/WireActivity.kt | 1385 ++--------------- .../android/ui/WireActivityActionsHandler.kt | 237 --- .../wire/android/ui/WireActivityDialogs.kt | 273 +++- .../WireActivityNavigation3DialogActions.kt | 111 ++ .../ui/WireActivityNavigation3Effects.kt | 361 +++++ .../android/ui/WireActivityNavigation3Host.kt | 593 +++++++ .../WireActivityNavigation3SessionEffects.kt | 151 ++ .../ui/WireActivityNavigation3Setup.kt | 96 ++ .../ui/WireActivityScopedViewModels.kt | 69 + .../wire/android/ui/WireActivityViewModel.kt | 113 +- .../AuthenticationViewModelGraph.kt | 192 ++- .../SessionAuthenticationViewModelFactory.kt | 98 ++ .../create/code/CreateAccountCodeScreen.kt | 40 +- .../create/code/CreateAccountCodeViewModel.kt | 13 +- .../create/common/CreateAccountNavGraph.kt | 40 - .../details/CreateAccountDetailsScreen.kt | 31 +- .../details/CreateAccountDetailsViewModel.kt | 9 +- .../create/email/CreateAccountEmailScreen.kt | 44 +- .../email/CreateAccountEmailViewModel.kt | 9 +- .../CreateAccountOverviewViewModel.kt | 8 +- .../CreatePersonalAccountOverviewScreen.kt | 48 +- .../summary/CreateAccountSummaryScreen.kt | 17 +- .../summary/CreateAccountSummaryViewModel.kt | 8 +- .../username/CreateAccountUsernameScreen.kt | 23 +- .../devices/common/ClearSessionViewModel.kt | 37 +- ...essionBackedAuthenticationLegacyMappers.kt | 39 + .../register/RegisterDeviceLegacyMappers.kt} | 21 +- .../devices/register/RegisterDeviceRoute.kt | 40 + .../devices/register/RegisterDeviceScreen.kt | 61 +- .../remove/RemoveDeviceLegacyMappers.kt} | 18 +- .../devices/remove/RemoveDeviceRoute.kt | 40 + .../devices/remove/RemoveDeviceScreen.kt | 53 +- .../ui/authentication/login/LoginNavGraph.kt | 27 - .../ui/authentication/login/LoginScreen.kt | 53 +- .../login/email/LoginEmailViewModel.kt | 195 ++- .../authentication/welcome/WelcomeScreen.kt | 74 +- .../welcome/WelcomeViewModel.kt | 9 +- .../wire/android/ui/calling/CallActivity.kt | 202 ++- .../android/ui/calling/CallActivityRouting.kt | 67 + .../ui/calling/CallActivityViewModel.kt | 16 +- .../calling/CallingManualViewModelFactory.kt | 43 +- .../calling/CallingMetroViewModelBindings.kt | 81 + .../ui/calling/StartingCallActivity.kt | 100 +- .../ui/calling/ongoing/OngoingCallActivity.kt | 67 +- .../ui/common/CommonMetroViewModelBindings.kt | 54 + ...odelFactory.kt => CommonViewModelGraph.kt} | 49 +- .../banner/SecurityClassificationArgs.kt | 4 +- .../banner/SecurityClassificationViewModel.kt | 29 +- .../topappbar/CommonTopAppBarViewModel.kt | 60 +- .../ui/debug/DebugDataOptionsViewModel.kt | 2 +- .../ui/debug/DebugInfoViewModelGraph.kt | 24 +- .../ui/debug/DebugMetroViewModelBindings.kt | 70 + .../com/wire/android/ui/debug/DebugScreen.kt | 45 +- .../ui/debug/ExportObfuscatedCopyViewModel.kt | 4 +- .../android/ui/debug/LogManagementScreen.kt | 32 +- .../ui/debug/LogManagementViewModel.kt | 2 +- .../android/ui/debug/UserDebugViewModel.kt | 2 +- .../conversation/DebugConversationScreen.kt | 15 +- .../DebugConversationViewModel.kt | 14 +- .../ConversationCryptoStatsScreen.kt | 9 +- .../ConversationCryptoStatsViewModel.kt | 2 +- .../featureflags/DebugFeatureFlagsScreen.kt | 9 +- .../DebugFeatureFlagsViewModel.kt | 2 +- .../E2EIEnrollmentLegacyMappers.kt | 16 +- .../ui/e2eiEnrollment/E2EIEnrollmentRoute.kt | 40 + .../ui/e2eiEnrollment/E2EIEnrollmentScreen.kt | 46 +- .../ui/home/HomeMetroViewModelBindings.kt | 66 + .../com/wire/android/ui/home/HomeNavGraph.kt | 26 - .../android/ui/home/HomeNavigation3Entry.kt | 488 ++++++ .../ui/home/HomeNavigation3TopLevelContent.kt | 121 ++ .../com/wire/android/ui/home/HomeScaffold.kt | 53 +- .../com/wire/android/ui/home/HomeScreen.kt | 215 +-- .../wire/android/ui/home/HomeShellState.kt | 51 + .../com/wire/android/ui/home/HomeState.kt | 17 - .../wire/android/ui/home/HomeStateHolder.kt | 153 -- .../com/wire/android/ui/home/HomeViewModel.kt | 31 +- .../android/ui/home/HomeViewModelGraph.kt | 27 +- .../ui/home/appLock/AppLockNavigation3.kt | 78 + .../home/appLock/AppLockNavigation3Entries.kt | 89 ++ .../appLock/forgot/ForgotLockCodeScreen.kt | 9 +- .../ui/home/appLock/set/SetLockCodeScreen.kt | 18 +- .../unlock/AppUnlockWithBiometricsScreen.kt | 41 +- .../appLock/unlock/EnterLockCodeScreen.kt | 22 +- .../android/ui/home/archive/ArchiveScreen.kt | 35 +- .../android/ui/home/cell/GlobalCellsScreen.kt | 17 +- .../CompositeMessageViewModel.kt | 16 +- .../ConversationAuxNavigation3.kt | 188 +++ .../ConversationAuxNavigation3Entries.kt | 198 +++ .../ConversationAuxNavigation3Mappers.kt | 96 ++ .../ConversationCoreViewModelGraph.kt | 100 +- .../ConversationDetailsViewModelGraph.kt | 96 +- .../conversations/ConversationNavigation3.kt | 73 + .../ConversationNavigation3Entries.kt | 345 ++++ .../ConversationNavigation3Mappers.kt | 43 + .../ConversationNavigationResults.kt | 151 -- .../conversations/ConversationRouteScreen.kt | 101 ++ .../home/conversations/ConversationScreen.kt | 300 ++-- ...ationSearchFolderMetroViewModelBindings.kt | 56 + .../ConversationSearchFolderViewModelGraph.kt | 46 +- .../ScopedMessageViewModelGraph.kt | 91 +- .../attachment/MessageAttachmentsViewModel.kt | 8 +- .../banner/ConversationBannerViewModel.kt | 8 +- .../call/ConversationCallViewModel.kt | 12 +- .../channels/BrowseChannelsScreen.kt | 10 +- .../composer/MessageComposerViewModel.kt | 8 +- .../details/ConversationDetailsNavigation3.kt | 173 ++ .../ConversationDetailsNavigation3Entries.kt | 316 ++++ .../ConversationDetailsNavigation3Mappers.kt | 88 ++ .../details/GroupConversationDetailsScreen.kt | 288 +--- .../GroupConversationDetailsViewModel.kt | 11 +- .../editguestaccess/EditGuestAccessScreen.kt | 50 +- .../EditGuestAccessViewModel.kt | 11 +- .../CreatePasswordGuestLinkViewModel.kt | 9 +- .../CreatePasswordProtectedGuestLinkScreen.kt | 15 +- .../EditSelfDeletingMessagesScreen.kt | 25 +- .../EditSelfDeletingMessagesViewModel.kt | 9 +- .../EditConversationMetadataViewModel.kt | 9 +- .../metadata/EditConversationNameScreen.kt | 30 +- .../GroupConversationAllParticipantsScreen.kt | 48 +- .../GroupConversationParticipantsViewModel.kt | 13 +- .../UpdateAppsAccessScreen.kt | 25 +- .../UpdateAppsAccessViewModel.kt | 11 +- .../ChannelAccessOnUpdateScreen.kt | 34 +- .../UpdateChannelAccessViewModel.kt | 16 +- .../edit/MessageOptionsMenuViewModel.kt | 9 +- .../folder/ConversationFoldersScreen.kt | 60 +- .../folder/NewConversationFolderScreen.kt | 21 +- .../info/ConversationInfoViewModel.kt | 8 +- .../ConversationAssetMessagesViewModel.kt | 10 +- .../media/ConversationMediaScreen.kt | 54 +- .../media/preview/ImagesPreviewScreen.kt | 23 +- .../media/preview/ImagesPreviewViewModel.kt | 13 +- .../messagedetails/MessageDetailsScreen.kt | 16 +- .../messagedetails/MessageDetailsViewModel.kt | 16 +- .../messages/ConversationMessagesViewModel.kt | 8 +- .../conversations/messages/QuotedMessage.kt | 1 + .../messages/draft/MessageDraftViewModel.kt | 8 +- .../messages/item/MessageContentAndStatus.kt | 3 + .../ConversationMigrationViewModel.kt | 10 +- .../model/CompositeMessageArgs.kt | 4 +- .../home/conversations/model/MessageTypes.kt | 6 +- .../promoteadmin/PromoteAdminScreen.kt | 18 +- .../promoteadmin/PromoteAdminViewModel.kt | 8 +- .../AddMembersSearchScreen.kt | 53 +- .../AddMembersToConversationViewModel.kt | 8 +- .../SearchConversationMessagesScreen.kt | 54 +- .../SearchConversationMessagesViewModel.kt | 10 +- .../sendmessage/SendMessageViewModel.kt | 8 +- .../typing/TypingIndicatorViewModel.kt | 5 +- .../videoplayer/VideoPlayerScreen.kt | 45 - .../ConversationsScreenContent.kt | 78 +- .../all/AllConversationsScreen.kt | 52 +- .../all/ConversationsEmptyContent.kt | 26 +- .../wire/android/ui/home/drawer/HomeDrawer.kt | 35 +- .../ui/home/drawer/HomeDrawerViewModel.kt | 15 +- .../ui/home/gallery/MediaGalleryScreen.kt | 65 +- .../ui/home/gallery/MediaGalleryViewModel.kt | 49 +- .../ui/home/meetings/MeetingsScreen.kt | 73 +- .../home/messagecomposer/MessageComposer.kt | 1 + .../actions/SelfDeletingMessageActionArgs.kt | 9 +- .../NewConversationNavigation3.kt | 138 ++ .../NewConversationNavigation3Entries.kt | 251 +++ .../ChannelAccessOnCreateScreen.kt | 12 +- .../ChannelHistoryCustomScreen.kt | 28 +- .../ChannelHistoryLegacyMappers.kt | 57 + .../ChannelHistoryNavigation3.kt | 106 ++ .../ChannelHistoryNavigation3Entries.kt | 141 ++ .../channelhistory/ChannelHistoryScreen.kt | 37 - .../groupOptions/GroupOptionsScreen.kt | 41 +- .../groupname/NewGroupNameScreen.kt | 33 +- .../NewGroupConversationSearchPeopleScreen.kt | 24 +- .../NewConversationSearchPeopleScreen.kt | 64 +- .../SettingsAccountNavigation3Entries.kt | 295 ++++ .../android/ui/home/settings/SettingsItem.kt | 36 - .../ui/home/settings/SettingsLegacyMappers.kt | 45 + .../ui/home/settings/SettingsNavigation3.kt | 242 +++ .../settings/SettingsNavigation3Entries.kt | 190 +++ .../ui/home/settings/SettingsScreen.kt | 31 +- .../home/settings/SettingsViewModelGraph.kt | 93 +- .../about/dependencies/DependenciesScreen.kt | 7 +- .../dependencies/DependenciesViewModel.kt | 2 +- .../settings/about/licenses/LicensesScreen.kt | 7 +- .../about/licenses/LicensesViewModel.kt | 2 +- .../home/settings/account/MyAccountScreen.kt | 117 +- .../account/color/ChangeUserColorScreen.kt | 24 +- .../displayname/ChangeDisplayNameScreen.kt | 24 +- .../email/updateEmail/ChangeEmailScreen.kt | 55 +- .../email/verifyEmail/VerifyEmailScreen.kt | 16 +- .../email/verifyEmail/VerifyEmailViewModel.kt | 9 +- .../verifyEmail/VerifyEmailViewModelArgs.kt | 25 + .../account/handle/ChangeHandleScreen.kt | 21 +- .../appearance/CustomizationScreen.kt | 7 +- .../settings/appsettings/AppSettingsScreen.kt | 2 - .../networkSettings/NetworkSettingsScreen.kt | 7 +- .../settings/backup/BackupAndRestoreScreen.kt | 13 +- .../settings/privacy/PrivacySettingsScreen.kt | 7 +- .../wire/android/ui/home/vault/VaultScreen.kt | 2 - .../android/ui/home/whatsnew/WhatsNewItem.kt | 12 - .../ui/home/whatsnew/WhatsNewScreen.kt | 44 +- .../ui/home/whatsnew/WhatsNewViewModel.kt | 2 +- .../ui/initialsync/InitialSyncScreen.kt | 31 +- .../JoinConversationViaDeepLinkDialog.kt | 3 +- .../code/NewLoginVerificationCodeScreen.kt | 23 +- .../newauthentication/login/NewLoginAction.kt | 4 +- .../newauthentication/login/NewLoginScreen.kt | 98 +- .../login/NewLoginViewModel.kt | 69 +- .../login/password/NewLoginPasswordScreen.kt | 101 +- .../welcome/NewWelcomeScreen.kt | 22 +- .../CreateAccountVerificationCodeScreen.kt | 47 +- .../CreateAccountVerificationCodeViewModel.kt | 13 +- .../CreateAccountVerificationCodeViewState.kt | 2 +- .../details/CreateAccountDataDetailScreen.kt | 48 +- .../CreateAccountDataDetailViewModel.kt | 9 +- .../selector/CreateAccountSelectorScreen.kt | 71 +- .../CreateAccountSelectorViewModel.kt | 8 +- .../ui/settings/about/AboutThisAppScreen.kt | 25 +- .../settings/about/AboutThisAppViewModel.kt | 2 +- .../settings/devices/DeviceDetailsScreen.kt | 34 +- .../devices/DeviceDetailsViewModel.kt | 11 +- .../devices/DeviceDetailsViewModelArgs.kt | 35 + .../devices/DeviceE2EINavigation3Entries.kt | 254 +++ .../devices/DeviceManagementLegacyMappers.kt | 39 + .../devices/DeviceManagementNavigation3.kt | 73 + .../ui/settings/devices/SelfDevicesScreen.kt | 19 +- .../E2eiCertificateDetailsLegacyMappers.kt | 43 + .../e2ei/E2eiCertificateDetailsNavigation3.kt | 59 + .../e2ei/E2eiCertificateDetailsScreen.kt | 18 +- .../e2ei/E2eiCertificateDetailsViewModel.kt | 23 +- .../E2eiCertificateDetailsViewModelArgs.kt | 55 + .../android/ui/sharing/ImportMediaScreen.kt | 97 +- .../UserProfileLegacyNavigationMappers.kt | 23 + .../UserProfileNavigation3Entries.kt | 320 ++++ .../ui/userprofile/UserProfileQualifiedId.kt | 38 + .../userprofile/avatarpicker/AvatarPicker.kt | 21 +- .../AvatarPickerLegacyNavigationMappers.kt | 15 + .../avatarpicker/AvatarPickerNavigation3.kt | 52 + ...OtherUserProfileLegacyNavigationMappers.kt | 40 + .../other/OtherUserProfileNavigation3.kt | 69 + .../other/OtherUserProfileScreen.kt | 108 +- .../other/OtherUserProfileScreenViewModel.kt | 16 +- .../ui/userprofile/qr/SelfQRCodeScreen.kt | 18 +- .../ui/userprofile/qr/SelfQRCodeViewModel.kt | 11 +- .../qr/SelfQrCodeLegacyNavigationMappers.kt | 34 + .../userprofile/qr/SelfQrCodeNavigation3.kt | 51 + .../userprofile/self/SelfUserProfileRoute.kt | 29 +- .../userprofile/self/SelfUserProfileScreen.kt | 104 +- .../ServiceDetailsLegacyNavigationMappers.kt | 61 + .../service/ServiceDetailsNavArgs.kt | 39 - .../service/ServiceDetailsNavigation3.kt | 66 + .../service/ServiceDetailsScreen.kt | 34 +- .../service/ServiceDetailsViewModel.kt | 42 +- .../PersonalToTeamMigrationNavGraph.kt | 26 - .../teammigration/TeamMigrationNavigation3.kt | 114 ++ .../TeamMigrationNavigation3Entries.kt | 149 ++ .../step1/TeamMigrationTeamPlanStepScreen.kt | 27 +- .../step2/TeamMigrationTeamNameStepScreen.kt | 23 +- .../TeamMigrationConfirmationStepScreen.kt | 34 +- .../step4/TeamMigrationDoneStepScreen.kt | 19 +- .../wire/android/util/CurrentScreenManager.kt | 128 +- .../util/deeplink/DeepLinkProcessor.kt | 75 +- .../navigation/TrackingNavController.kt | 22 - .../navigation/TrackingNavController.kt | 8 - .../android/config/NavigationTestExtension.kt | 49 - ...ScopedMessageManualViewModelFactoryTest.kt | 10 +- .../android/navigation/NavigationUtilsTest.kt | 186 --- .../navigation/TabletDialogRoutePolicyTest.kt | 95 -- .../Navigation3ContractSourcePurityTest.kt | 61 + .../AuthenticationNavigation3EntriesTest.kt | 118 ++ ...thenticationNavigation3RouterSourceTest.kt | 84 + .../routes/auth/AuthenticationRoutesTest.kt | 231 +++ .../AuthenticationTransitionLedgerTest.kt | 125 ++ .../auth/CreateAccountNavigation3Test.kt | 166 ++ .../media/MediaNavigation3SourceTest.kt | 46 + .../routes/media/MediaNavigation3Test.kt | 50 + .../utility/UtilityNavigation3SourceTest.kt | 66 + .../routes/utility/UtilityRoutesTest.kt | 65 + .../MetroWireEntryGraphResolverTest.kt | 144 ++ ...avigation3LegacyScreenCleanupSourceTest.kt | 82 + ...ion3RecreationResultOwnershipSourceTest.kt | 65 + .../runtime/SessionGraphStoreViewModelTest.kt | 435 ++++++ .../runtime/WireActivityDialogsSourceTest.kt | 78 + .../WireActivityIntentCoordinatorTest.kt | 186 +++ .../runtime/WireActivityIntentDecisionTest.kt | 51 + ...tivityNavigation3GraphContextSourceTest.kt | 44 + ...vityProductionNavigation3HostSourceTest.kt | 93 ++ .../WireActivityStructureSourceTest.kt | 64 + ...ireActivityViewModelOwnershipSourceTest.kt | 67 + ...reNavigation3ActivityEffectsAndroidTest.kt | 84 + .../WireNavigation3ActivityEffectsTest.kt | 110 ++ .../WireNavigation3ActivityPolicyTest.kt | 92 ++ .../WireNavigation3ContributionsTest.kt | 152 ++ ...eNavigation3ProductionActionsSourceTest.kt | 48 + .../WireNavigation3ProductionActionsTest.kt | 228 +++ ...WireNavigation3ProductionHostSourceTest.kt | 71 + ...reNavigation3ResponsivePresentationTest.kt | 106 ++ ...WireNavigation3SwitchAccountActionsTest.kt | 92 ++ .../runtime/WireNavigationDiagnosticsTest.kt | 71 + .../runtime/WireRouteScopeResolverTest.kt | 105 ++ .../WireSessionNavigationPolicyTest.kt | 140 ++ .../startup/WireInitialRouteResolverTest.kt | 123 ++ .../android/ui/CallActivityViewModelTest.kt | 32 +- .../EntryOwnedViewModelGatewaySourceTest.kt | 270 ++++ .../ui/SessionGraphStoreViewModelTest.kt | 95 +- .../ui/WireActivityActiveGraphResolverTest.kt | 431 ----- ...ireActivityNavigation3EffectsSourceTest.kt | 61 + .../ui/WireActivityNavigation3EffectsTest.kt | 244 +++ ...reActivityNavigation3SessionEffectsTest.kt | 154 ++ .../ui/WireActivityNavigation3SetupTest.kt | 84 + .../ui/WireActivitySessionLifecycleTest.kt | 75 + .../android/ui/WireActivityViewModelTest.kt | 76 +- ...henticationViewModelOwnershipSourceTest.kt | 58 + .../CreateAccountDetailsViewModelTest.kt | 17 +- .../email/CreateAccountEmailViewModelTest.kt | 18 +- .../common/ClearSessionViewModelTest.kt | 52 + .../login/email/LoginEmailViewModelTest.kt | 70 +- .../login/sso/LoginSSOViewModelTest.kt | 3 +- .../welcome/WelcomeViewModelTest.kt | 16 +- .../ui/calling/CallActivityRoutingTest.kt | 75 + ...allActivityViewModelOwnershipSourceTest.kt | 46 + .../ui/calling/SharedCallingViewModelTest.kt | 3 +- .../incoming/IncomingCallViewModelTest.kt | 2 - .../topappbar/CommonTopAppBarViewModelTest.kt | 4 +- .../DebugMetroViewModelBindingsSourceTest.kt | 35 + .../DebugConversationViewModelTest.kt | 13 +- .../HomeMetroViewModelBindingsSourceTest.kt | 37 + .../ui/home/HomeNavigation3EntryTest.kt | 141 ++ .../android/ui/home/HomeStateHolderTest.kt | 99 -- .../ui/home/HomeTopLevelNavigation3Test.kt | 96 ++ .../wire/android/ui/home/HomeViewModelTest.kt | 42 +- .../ui/home/appLock/AppLockNavigation3Test.kt | 147 ++ .../CompositeMessageViewModelTest.kt | 13 +- .../ConversationAuxNavigation3SourceTest.kt | 79 + .../ConversationAuxNavigation3Test.kt | 74 + .../ConversationNavigation3SourceTest.kt | 74 + .../ConversationNavigation3Test.kt | 66 + .../MessageAttachmentsViewModelTest.kt | 10 +- .../banner/ConversationBannerViewModelTest.kt | 17 +- .../SecurityClassificationViewModelTest.kt | 56 +- .../call/ConversationCallViewModelTest.kt | 11 +- .../MessageComposerViewModelArrangement.kt | 8 +- .../composer/MessageComposerViewModelTest.kt | 2 - ...onversationDetailsNavigation3SourceTest.kt | 79 + .../ConversationDetailsNavigation3Test.kt | 57 + .../details/GroupDetailsViewModelTest.kt | 20 +- .../CreatePasswordGuestLinkViewModelTest.kt | 17 +- .../EditGuestAccessViewModelTest.kt | 26 +- .../EditSelfDeletingMessagesViewModelTest.kt | 14 +- .../EditConversationMetadataViewModelTest.kt | 14 +- .../GroupParticipantsViewModelTest.kt | 13 +- .../UpdateAppsAccessViewModelTest.kt | 35 +- .../UpdateChannelAccessViewModelTest.kt | 19 +- .../ConversationInfoViewModelArrangement.kt | 8 +- .../info/ConversationInfoViewModelTest.kt | 2 - ...onversationMessagesViewModelArrangement.kt | 8 +- .../ConversationMessagesViewModelTest.kt | 2 - ...SearchConversationMessagesViewModelTest.kt | 19 +- .../draft/MessageDraftViewModelTest.kt | 20 +- .../ConversationMigrationViewModelTest.kt | 14 +- .../promoteadmin/PromoteAdminViewModelTest.kt | 21 +- .../AddMembersToConversationViewModelTest.kt | 14 +- .../SendMessageViewModelArrangement.kt | 15 +- .../sendmessage/SendMessageViewModelTest.kt | 2 - .../ui/home/drawer/HomeDrawerViewModelTest.kt | 7 - .../home/gallery/MediaGalleryViewModelTest.kt | 37 +- .../NewConversationNavigation3Test.kt | 171 ++ .../ChannelHistoryNavigation3Test.kt | 167 ++ ...ingsAccountNavigation3EntriesSourceTest.kt | 95 ++ .../home/settings/SettingsNavigation3Test.kt | 213 +++ .../account/email/VerifyEmailViewModelTest.kt | 21 +- .../login/NewLoginViewModelTest.kt | 208 +-- .../CreateAccountDataDetailViewModelTest.kt | 14 +- .../CreateAccountSelectorViewModelTest.kt | 16 +- .../devices/DeviceDetailsViewModelTest.kt | 13 +- .../DeviceE2EINavigation3EntriesSourceTest.kt | 87 ++ .../DeviceManagementNavigation3Test.kt | 101 ++ .../E2eiCertificateDetailsNavigation3Test.kt | 94 ++ .../UserProfileNavigation3SourcePurityTest.kt | 118 ++ .../userprofile/UserProfileNavigation3Test.kt | 36 + .../AvatarPickerNavigation3Test.kt | 66 + .../other/OtherUserProfileNavigation3Test.kt | 91 ++ .../OtherUserProfileScreenViewModelTest.kt | 2 - .../OtherUserProfileViewModelArrangement.kt | 24 +- .../userprofile/qr/SelfQRCodeViewModelTest.kt | 11 +- .../qr/SelfQrCodeNavigation3Test.kt | 56 + .../self/SelfUserProfileNavigation3Test.kt | 46 + .../self/SelfUserProfileViewModelTest.kt | 2 - .../service/ServiceDetailsNavigation3Test.kt | 82 + .../service/ServiceDetailsViewModelTest.kt | 22 +- .../TeamMigrationNavigation3SourceTest.kt | 98 ++ .../TeamMigrationNavigation3Test.kt | 117 ++ .../android/util/DeepLinkProcessorTest.kt | 4 +- build-logic/plugins/build.gradle.kts | 4 - .../src/main/kotlin/AndroidCoordinates.kt | 2 +- .../AndroidNavigationConventionPlugin.kt | 34 - buildSrc/build.gradle.kts | 2 +- core/di/build.gradle.kts | 4 + .../com/wire/android/di/ViewModelScoped.kt | 61 +- .../android/di/metro/MetroViewModelGraph.kt | 139 +- .../android/di/metro/WireMetroViewModel.kt | 177 +++ .../di/metro/WireViewModelDiagnostics.kt | 63 + .../di/metro/WireMetroViewModelTest.kt | 85 + .../di/metro/WireViewModelDiagnosticsTest.kt | 88 ++ core/di/stability/di-debug.stability | 57 +- .../mediaplayer/MediaPlayerViewModelGraph.kt | 6 +- .../mediaplayer/VideoPlayerViewModel.kt | 2 +- .../MediaPlayerAssistedFactorySourceTest.kt | 37 + core/navigation-kmp/build.gradle.kts | 30 + .../wire/navigation/WireBackStackReducer.kt | 84 + .../wire/navigation/WireDeepLinkResolver.kt | 128 ++ .../com/wire/navigation/WireNavResult.kt | 140 ++ .../wire/navigation/WireNavResultRegistry.kt | 242 +++ .../wire/navigation/WireNavigationCommand.kt | 22 +- .../navigation/WireNavigationController.kt | 106 ++ .../kotlin/com/wire/navigation/WireRoute.kt | 96 ++ .../com/wire/navigation/WireViewModelOwner.kt | 87 ++ .../navigation/WireBackStackReducerTest.kt | 114 ++ .../navigation/WireDeepLinkResolverTest.kt | 184 +++ .../navigation/WireNavResultRegistryTest.kt | 294 ++++ .../WireNavigationControllerTest.kt | 111 ++ .../wire/navigation/WireViewModelOwnerTest.kt | 181 +++ core/navigation/build.gradle.kts | 14 +- .../android/navigation/NavigationCommand.kt | 77 - .../android/navigation/PreviewNavigator.kt | 25 - .../navigation/PreviewResultBackNavigator.kt | 28 - .../navigation/PreviewResultRecipient.kt | 45 - .../wire/android/navigation/WireNavigator.kt | 28 - .../navigation3/WireEntryPresentation.kt | 162 ++ .../navigation3/WireEntryProvider.kt | 83 + .../navigation/navigation3/WireNav3Host.kt | 146 ++ .../navigation3/WireNavigation3Results.kt | 156 ++ .../navigation3/WireNavigation3Runtime.kt | 227 +++ .../WireResponsivePresentationPolicy.kt | 94 ++ .../WireTransitionInteractionGate.kt | 72 + .../WireViewModelStoreNavEntryDecorator.kt | 303 ++++ .../navigation/style/BackgroundStyle.kt | 26 - .../navigation/style/BackgroundType.kt | 17 + .../style/DefaultNavGraphAnimations.kt | 53 - .../style/NavigationAnimationStyles.kt | 57 - .../navigation/style/TransitionAnimations.kt | 29 + .../style/WireDestinationStyleAnimated.kt | 103 -- .../navigation/wrapper/TabletDialogWrapper.kt | 60 - .../wrapper/WaitUntilTransitionEndsWrapper.kt | 64 - .../Navigation3OnlyModuleSourceTest.kt | 55 + .../navigation3/WireEntryPresentationTest.kt | 124 ++ .../navigation3/WireNavigation3RuntimeTest.kt | 259 +++ .../WireResponsivePresentationPolicyTest.kt | 172 ++ .../WireSharedViewModelStoreOwnerTest.kt | 156 ++ .../WireViewModelOwnershipTrackerFlowTest.kt | 111 ++ .../WireViewModelOwnershipTrackerTest.kt | 110 ++ .../WireViewModelStoreRegistryTest.kt | 188 +++ .../stability/navigation-debug.stability | 78 +- core/search/build.gradle.kts | 3 +- .../android/search/SearchViewModelGraph.kt | 12 +- core/ui-common/build.gradle.kts | 4 +- .../CoreUICommonManualViewModelFactory.kt | 12 +- .../CoreUICommonMetroViewModelBindings.kt | 4 +- .../ConnectionActionButtonViewModel.kt | 7 +- .../common/topappbar/NavigationIconButton.kt | 9 +- ...o-navigation-3-with-kmp-ready-contracts.md | 82 + features/cells/build.gradle.kts | 11 +- .../feature/cells/navigation/CellsNavGraph.kt | 26 - .../cells/navigation/CellsNavigation3.kt | 234 +++ .../navigation/CellsNavigation3Entries.kt | 100 ++ .../navigation/CellsNavigation3Mappers.kt | 81 + .../navigation/CellsNavigation3Renderer.kt | 419 +++++ .../cells/ui/AllFilesNavigationActions.kt | 29 + .../feature/cells/ui/AllFilesScreen.kt | 125 +- .../android/feature/cells/ui/CellViewModel.kt | 22 +- .../feature/cells/ui/CellsFilesNavigation.kt | 46 + .../cells/ui/CellsMetroViewModelBindings.kt | 154 +- .../feature/cells/ui/CellsViewModelGraph.kt | 91 +- .../cells/ui/ConversationFilesScreen.kt | 180 +-- ...rsationFilesWithSlideInTransitionScreen.kt | 32 +- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 16 +- .../ui/audioplayer/AudioPlayerViewModel.kt | 16 +- .../cells/ui/create/file/CreateFileScreen.kt | 30 +- .../ui/create/file/CreateFileViewModel.kt | 8 +- .../ui/create/folder/CreateFolderScreen.kt | 30 +- .../ui/create/folder/CreateFolderViewModel.kt | 8 +- .../ui/imageviewer/CellImageViewerScreen.kt | 25 - .../imageviewer/CellImageViewerViewModel.kt | 8 +- .../ui/movetofolder/MoveToFolderScreen.kt | 74 +- .../ui/movetofolder/MoveToFolderViewModel.kt | 8 +- .../cells/ui/publiclink/PublicLinkScreen.kt | 74 +- .../ui/publiclink/PublicLinkViewModel.kt | 8 +- .../expiration/PublicLinkExpirationScreen.kt | 21 +- .../PublicLinkExpirationScreenViewModel.kt | 8 +- .../password/PublicLinkPasswordScreen.kt | 18 +- .../PublicLinkPasswordScreenViewModel.kt | 8 +- .../cells/ui/recyclebin/RecycleBinScreen.kt | 82 +- .../cells/ui/rename/RenameNodeScreen.kt | 23 +- .../cells/ui/rename/RenameNodeViewModel.kt | 8 +- .../feature/cells/ui/search/SearchScreen.kt | 141 +- .../cells/ui/search/SearchScreenViewModel.kt | 8 +- .../cells/ui/tags/AddRemoveTagsScreen.kt | 17 +- .../cells/ui/tags/AddRemoveTagsViewModel.kt | 7 +- .../ui/versioning/VersionHistoryScreen.kt | 15 +- .../ui/versioning/VersionHistoryViewModel.kt | 8 +- .../cells/ui/videoplayer/VideoPlayerScreen.kt | 45 - .../features/cells/WireCellsDestination.kt | 36 - .../android/config/NavigationTestExtension.kt | 49 - .../navigation/CellsNavigation3SourceTest.kt | 76 + .../cells/navigation/CellsNavigation3Test.kt | 91 ++ .../feature/cells/ui/CellViewModelTest.kt | 88 +- .../audioplayer/AudioPlayerViewModelTest.kt | 17 +- .../ui/create/file/CreateFileViewModelTest.kt | 11 +- .../folder/CreateFolderViewModelTest.kt | 8 +- .../movetofolder/MoveToFolderViewModelTest.kt | 15 +- .../ui/publiclink/PublicLinkViewModelTest.kt | 19 +- ...PublicLinkExpirationScreenViewModelTest.kt | 8 +- .../PublicLinkPasswordScreenViewModelTest.kt | 14 +- .../ui/rename/RenameNodeViewModelTest.kt | 26 +- .../ui/search/SearchScreenViewModelTest.kt | 38 +- .../ui/tags/AddRemoveTagsViewModelTest.kt | 9 +- .../versioning/VersionHistoryViewModelTest.kt | 27 +- .../cells/stability/cells-debug.stability | 261 ++-- features/meetings/build.gradle.kts | 10 +- .../meetings/navigation/NewMeetingNavGraph.kt | 26 - .../ui/MeetingsMetroViewModelBindings.kt | 27 +- .../meetings/ui/MeetingsViewModelGraph.kt | 26 +- .../meetings/ui/create/MeetingsNavigation3.kt | 103 ++ .../ui/create/MeetingsNavigation3Entries.kt | 121 ++ .../ui/create/NewMeetingParticipantsScreen.kt | 17 +- .../meetings/ui/create/NewMeetingScreen.kt | 133 +- .../meetings/ui/create/NewMeetingViewModel.kt | 7 +- .../meetings/ui/list/MeetingListViewModel.kt | 2 + .../meetings/WireNewMeetingDestination.kt | 36 - .../android/config/NavigationTestExtension.kt | 49 - .../create/MeetingsNavigation3SourceTest.kt | 55 + .../ui/create/MeetingsNavigation3Test.kt | 70 + .../ui/create/NewMeetingViewModelTest.kt | 395 +---- .../stability/meetings-debug.stability | 49 +- features/sketch/build.gradle.kts | 15 +- .../feature/sketch/DrawingCanvasScreen.kt | 53 +- .../feature/sketch/DrawingCanvasViewModel.kt | 11 +- .../sketch/SketchMetroViewModelBindings.kt | 26 + .../sketch/navigation/SketchNavGraph.kt | 26 - .../sketch/navigation/SketchNavigation3.kt | 42 + .../navigation/SketchNavigation3Entries.kt | 67 + .../features/sketch/WireSketchDestination.kt | 36 - .../sketch/DrawingCanvasViewModelTest.kt | 23 +- .../feature/sketch/NavigationTestExtension.kt | 54 - .../navigation/SketchNavigation3Test.kt | 93 ++ .../sketch/stability/sketch-debug.stability | 14 +- features/sync/build.gradle.kts | 13 - gradle/libs.versions.toml | 14 +- kalium | 2 +- .../core/criticalFlows/AccountManagement.kt | 2 +- .../core/e2eTests/MultiAccountSessionScope.kt | 63 +- .../core/pages/ConnectedUserProfilePage.kt | 1 - .../tests/core/pages/ConversationListPage.kt | 28 +- .../tests/core/pages/ConversationViewPage.kt | 3 +- .../android/tests/core/pages/SearchPage.kt | 1 - .../src/main/service/TestService.kt | 4 +- 608 files changed, 30996 insertions(+), 13087 deletions(-) delete mode 100644 app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt create mode 100644 app/src/main/kotlin/com/wire/android/di/metro/AuthenticationMetroViewModelBindings.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/Navigator.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/TabletDialogRoutePolicy.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/WireNavHostEngine.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/WireRootNavGraph.kt delete mode 100644 app/src/main/kotlin/com/wire/android/navigation/annotation/app/AppDestinations.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutes.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountLegacyMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Mappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutes.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/MetroWireEntryEnvironment.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/Navigation3RouteObservation.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModel.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityGraphContext.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinator.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffects.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHost.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentation.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnostics.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolver.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicy.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/startup/HomeRoute.kt create mode 100644 app/src/main/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolver.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3DialogActions.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Effects.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffects.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Setup.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/WireActivityScopedViewModels.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/SessionAuthenticationViewModelFactory.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/create/common/CreateAccountNavGraph.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/SessionBackedAuthenticationLegacyMappers.kt rename app/src/main/kotlin/com/wire/android/{di/metro/AppImageAssetViewModelGraph.kt => ui/authentication/devices/register/RegisterDeviceLegacyMappers.kt} (57%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceRoute.kt rename app/src/main/kotlin/com/wire/android/ui/{home/newconversation/common/NewConversationNavGraph.kt => authentication/devices/remove/RemoveDeviceLegacyMappers.kt} (56%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceRoute.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavGraph.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/CallActivityRouting.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/CallingMetroViewModelBindings.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/common/CommonMetroViewModelBindings.kt rename app/src/main/kotlin/com/wire/android/ui/common/{CommonViewModelFactory.kt => CommonViewModelGraph.kt} (62%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindings.kt rename features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt => app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentLegacyMappers.kt (59%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentRoute.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindings.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/home/HomeNavGraph.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/HomeShellState.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Mappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Mappers.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigationResults.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderMetroViewModelBindings.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Mappers.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryLegacyMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsLegacyMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModelArgs.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelArgs.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementLegacyMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsLegacyMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModelArgs.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileLegacyNavigationMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Entries.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileQualifiedId.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerLegacyNavigationMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileLegacyNavigationMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeLegacyNavigationMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3.kt rename features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt => app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileRoute.kt (55%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsLegacyNavigationMappers.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3.kt delete mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/PersonalToTeamMigrationNavGraph.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Entries.kt delete mode 100644 app/src/private/kotlin/com/wire/android/navigation/TrackingNavController.kt delete mode 100644 app/src/public/kotlin/com/wire/android/navigation/TrackingNavController.kt delete mode 100644 app/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt delete mode 100644 app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt delete mode 100644 app/src/test/kotlin/com/wire/android/navigation/TabletDialogRoutePolicyTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/Navigation3ContractSourcePurityTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3EntriesTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationTransitionLedgerTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3SourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3SourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutesTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/MetroWireEntryGraphResolverTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3LegacyScreenCleanupSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3RecreationResultOwnershipSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModelTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityDialogsSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinatorTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentDecisionTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityNavigation3GraphContextSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityProductionNavigation3HostSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityStructureSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityViewModelOwnershipSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsAndroidTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHostSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentationTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnosticsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolverTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicyTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolverTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/EntryOwnedViewModelGatewaySourceTest.kt delete mode 100644 app/src/test/kotlin/com/wire/android/ui/WireActivityActiveGraphResolverTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffectsTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SetupTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/WireActivitySessionLifecycleTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelOwnershipSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/calling/CallActivityRoutingTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/calling/CallActivityViewModelOwnershipSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindingsSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindingsSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/HomeNavigation3EntryTest.kt delete mode 100644 app/src/test/kotlin/com/wire/android/ui/home/HomeStateHolderTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3SourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3SourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3SourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3EntriesSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3SourcePurityTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3Test.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3SourceTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Test.kt delete mode 100644 build-logic/plugins/src/main/kotlin/AndroidNavigationConventionPlugin.kt create mode 100644 core/di/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModel.kt create mode 100644 core/di/src/main/kotlin/com/wire/android/di/metro/WireViewModelDiagnostics.kt create mode 100644 core/di/src/test/kotlin/com/wire/android/di/metro/WireMetroViewModelTest.kt create mode 100644 core/di/src/test/kotlin/com/wire/android/di/metro/WireViewModelDiagnosticsTest.kt create mode 100644 core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt create mode 100644 core/navigation-kmp/build.gradle.kts create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireBackStackReducer.kt create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireDeepLinkResolver.kt create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResult.kt create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResultRegistry.kt rename app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.kt => core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationCommand.kt (62%) create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationController.kt create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt create mode 100644 core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireViewModelOwner.kt create mode 100644 core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireBackStackReducerTest.kt create mode 100644 core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireDeepLinkResolverTest.kt create mode 100644 core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavResultRegistryTest.kt create mode 100644 core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavigationControllerTest.kt create mode 100644 core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireViewModelOwnerTest.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/NavigationCommand.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewNavigator.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultBackNavigator.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultRecipient.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/WireNavigator.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentation.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryProvider.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNav3Host.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Results.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicy.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireTransitionInteractionGate.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreNavEntryDecorator.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundStyle.kt create mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundType.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/style/DefaultNavGraphAnimations.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/style/WireDestinationStyleAnimated.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/TabletDialogWrapper.kt delete mode 100644 core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/WaitUntilTransitionEndsWrapper.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/Navigation3OnlyModuleSourceTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentationTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireNavigation3RuntimeTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicyTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireSharedViewModelStoreOwnerTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerFlowTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerTest.kt create mode 100644 core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreRegistryTest.kt create mode 100644 docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavGraph.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Mappers.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt delete mode 100644 features/cells/src/main/java/com/wire/android/navigation/annotation/features/cells/WireCellsDestination.kt delete mode 100644 features/cells/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt create mode 100644 features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3SourceTest.kt create mode 100644 features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt delete mode 100644 features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/NewMeetingNavGraph.kt create mode 100644 features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3.kt create mode 100644 features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt delete mode 100644 features/meetings/src/main/java/com/wire/android/navigation/annotation/features/meetings/WireNewMeetingDestination.kt delete mode 100644 features/meetings/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt create mode 100644 features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt create mode 100644 features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Test.kt create mode 100644 features/sketch/src/main/java/com/wire/android/feature/sketch/SketchMetroViewModelBindings.kt delete mode 100644 features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavGraph.kt create mode 100644 features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3.kt create mode 100644 features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Entries.kt delete mode 100644 features/sketch/src/main/java/com/wire/android/navigation/annotation/features/sketch/WireSketchDestination.kt delete mode 100644 features/sketch/src/test/java/com/wire/android/feature/sketch/NavigationTestExtension.kt create mode 100644 features/sketch/src/test/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Test.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9003f9d39ef..14349521803 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,6 @@ plugins { id(libs.plugins.wire.kover.get().pluginId) id(libs.plugins.wire.versionizer.get().pluginId) alias(libs.plugins.screenshot) - id(libs.plugins.wire.android.navigation.get().pluginId) } repositories { @@ -66,6 +65,17 @@ private fun getFlavorsSettings(): NormalizedFlavorSettings = android { defaultConfig { + val navigationDiagnosticsEnabled = providers + .gradleProperty("wire.navigation.diagnostics") + .orNull + ?.toBooleanStrictOrNull() + ?: false + buildConfigField( + "boolean", + "NAVIGATION_DIAGNOSTICS_ENABLED", + navigationDiagnosticsEnabled.toString(), + ) + ndk { abiFilters.apply { add("armeabi-v7a") @@ -178,10 +188,6 @@ baselineProfile { dexLayoutOptimization = true } -ksp { - arg("compose-destinations.moduleName", "app") -} - // Skip AboutLibraries configuration when running lint to reduce memory usage if (!project.hasProperty("skip.aboutlibraries")) { aboutLibraries { @@ -207,6 +213,7 @@ dependencies { implementationWithCoverage(projects.core.media) implementationWithCoverage(projects.core.mediaPlayer) implementationWithCoverage(projects.core.notification) + implementationWithCoverage(projects.core.navigation) implementationWithCoverage(projects.core.search) implementationWithCoverage(projects.features.cells) implementationWithCoverage(projects.features.sketch) @@ -287,7 +294,6 @@ dependencies { // smaller view models implementation(libs.resaca.core) - implementation(libs.resaca.metro) implementation(libs.metrox.viewModelCompose) implementation(libs.bundlizer.core) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a31e9fba096..33db89c06f3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -39,8 +39,6 @@ #-keepattributes RuntimeVisibleAnnotations,AnnotationDefault #-keepattributes InnerClasses # Needed for `getDeclaredClasses`. --keepnames class * extends com.ramcosta.composedestinations.spec.Route - # For JNA -dontwarn java.awt.Component -dontwarn java.awt.GraphicsEnvironment diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt deleted file mode 100644 index 825e307ba2e..00000000000 --- a/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.ui - -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable -import androidx.navigation.createGraph -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotSame -import org.junit.Assert.assertNull -import org.junit.Rule -import org.junit.Test - -class WireActivityNavigatorTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun givenBlockingStateChanges_whenRememberingNavigator_thenNavigationStateIsDiscarded() { - val isUserUiBlocked = mutableStateOf(false) - lateinit var currentController: NavHostController - - composeTestRule.setContent { - currentController = rememberWireActivityNavigator( - isUserUiBlocked = isUserUiBlocked.value, - finish = {}, - isAllowedToNavigate = { true }, - ).navController - } - - lateinit var sessionController: NavHostController - composeTestRule.runOnIdle { - sessionController = currentController - isUserUiBlocked.value = true - } - composeTestRule.waitForIdle() - - lateinit var blockedController: NavHostController - composeTestRule.runOnIdle { - blockedController = currentController - isUserUiBlocked.value = false - } - composeTestRule.waitForIdle() - - composeTestRule.runOnIdle { - assertNotSame(sessionController, blockedController) - assertNotSame(blockedController, currentController) - assertNotSame(sessionController, currentController) - } - } - - @Test - fun givenNavigatorIsRecreatedWithoutActivityRecreation_whenReadingBackStack_thenStateTracksNewGraph() { - val isUserUiBlocked = mutableStateOf(false) - lateinit var currentController: NavHostController - var currentRoute: String? = null - - composeTestRule.setContent { - val navigator = rememberWireActivityNavigator( - isUserUiBlocked = isUserUiBlocked.value, - finish = {}, - isAllowedToNavigate = { true }, - ) - currentController = navigator.navController - currentRoute = wireActivityCurrentBackStackEntryAsState(navigator) - .value - ?.destination - ?.route - } - - composeTestRule.runOnIdle { - currentController.graph = currentController.createGraph(startDestination = SESSION_ROUTE) { - composable(SESSION_ROUTE) {} - } - } - composeTestRule.waitForIdle() - composeTestRule.runOnIdle { - assertEquals(SESSION_ROUTE, currentRoute) - isUserUiBlocked.value = true - } - composeTestRule.waitForIdle() - composeTestRule.runOnIdle { - assertNull(currentRoute) - isUserUiBlocked.value = false - } - composeTestRule.waitForIdle() - composeTestRule.runOnIdle { - assertNull(currentRoute) - currentController.graph = currentController.createGraph(startDestination = AUTH_ROUTE) { - composable(AUTH_ROUTE) {} - } - } - composeTestRule.waitForIdle() - composeTestRule.runOnIdle { - assertEquals(AUTH_ROUTE, currentRoute) - } - } - - private companion object { - const val AUTH_ROUTE = "authentication" - const val SESSION_ROUTE = "session" - } -} diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index a53ddb057d4..e4051e24725 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -35,6 +35,7 @@ import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic import com.wire.android.di.metro.WireApplicationGraph +import com.wire.android.di.metro.WireViewModelDiagnostics import com.wire.android.di.metro.createWireApplicationGraph import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl @@ -42,6 +43,7 @@ import com.wire.android.feature.analytics.AnonymousAnalyticsRecorderImpl import com.wire.android.feature.analytics.globalAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.feature.analytics.model.AnalyticsSettings +import com.wire.android.navigation.runtime.WireNavigationDiagnostics import com.wire.android.util.AppNameUtil import com.wire.android.util.CurrentScreenManager import com.wire.android.util.DataDogLogger @@ -128,6 +130,9 @@ class WireApplication : BaseApp() { appGraph.inject(this) super.onCreate() initializeMinimalLogging() + if (WireNavigationDiagnostics.enabled) { + WireViewModelDiagnostics.install(WireNavigationDiagnostics) + } enableStrictMode() diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt index 4e0d583b11e..745a88415b0 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -30,7 +30,6 @@ import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl import com.wire.android.mapper.MessageResourceProvider import com.wire.android.ui.analytics.AnalyticsConfiguration -import com.wire.android.ui.home.conversations.MessageSharedState import com.wire.android.ui.home.messagecomposer.location.LocationPickerParameters import com.wire.android.util.CurrentTimeProvider import com.wire.android.util.GetMediaMetadataUseCase @@ -127,10 +126,6 @@ object AppModule { @Named("inAppImageViewerEnabled") fun provideInAppImageViewerEnabled(): Boolean = BuildConfig.IN_APP_IMAGE_VIEWER_ENABLED - @Provides - @SingleIn(AppScope::class) - fun provideMessageSharedState(): MessageSharedState = MessageSharedState() - @Provides fun provideGetMediaMetadataUseCase(): GetMediaMetadataUseCase = GetMediaMetadataUseCaseImpl() } diff --git a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt index 067e8aa564c..93289509663 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -62,7 +62,6 @@ import com.wire.kalium.logic.feature.server.GetServerConfigUseCase import com.wire.kalium.logic.feature.server.ServerConfigForAccountUseCase import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase -import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.session.DeleteSessionUseCase import com.wire.kalium.logic.feature.session.DoesValidNomadAccountExistUseCase @@ -94,8 +93,6 @@ import com.wire.kalium.logic.util.RandomPassword import com.wire.kalium.network.NetworkStateObserver import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides -import dev.zacsweers.metro.Inject -import kotlinx.coroutines.runBlocking import dev.zacsweers.metro.Qualifier import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.SingleIn @@ -112,18 +109,6 @@ annotation class NoSession @Retention(AnnotationRetention.BINARY) annotation class DefaultWebSocketEnabledByDefault -@SingleIn(AppScope::class) -class LastKnownCurrentAccount @Inject constructor() { - @Volatile - private var userId: UserId? = null - - fun update(userId: UserId) { - this.userId = userId - } - - fun get(): UserId? = userId -} - @BindingContainer class CoreLogicModule { @@ -200,26 +185,6 @@ class CoreLogicModule { @BindingContainer class SessionModule { - // TODO: remove this fallback once root/auth ViewModel graphs no longer include session ViewModel bindings. - - @CurrentAccount - @Provides - fun provideCurrentSession( - @KaliumCoreLogic coreLogic: CoreLogic, - lastKnownCurrentAccount: LastKnownCurrentAccount, - ): UserId { - return runBlocking { - return@runBlocking when (val result = coreLogic.getGlobalScope().session.currentSession.invoke()) { - is CurrentSessionResult.Success -> result.accountInfo.userId.also(lastKnownCurrentAccount::update) - else -> { - // During logout, Compose may still dispose/move old session content and ask the root factory for - // account-scoped dependencies after Kalium has already cleared the current session. - lastKnownCurrentAccount.get() ?: throw IllegalStateException("no current session was found") - } - } - } - } - @Provides fun provideCurrentAccountUserDataStore( @CurrentAccount currentAccount: UserId, diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt index 1cccbc689fd..72747473c45 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt @@ -19,19 +19,43 @@ package com.wire.android.di.metro import com.wire.android.di.CurrentAccount import com.wire.android.di.ImageLoadingModule -import com.wire.android.ui.MiscViewModelGraph +import com.wire.android.di.SessionModule +import com.wire.android.di.accountScoped.AppsModule +import com.wire.android.di.accountScoped.AuthenticationModule +import com.wire.android.di.accountScoped.BackupModule +import com.wire.android.di.accountScoped.CallsModule +import com.wire.android.di.accountScoped.CellsModule +import com.wire.android.di.accountScoped.ChannelsModule +import com.wire.android.di.accountScoped.ClientModule +import com.wire.android.di.accountScoped.ConnectionModule +import com.wire.android.di.accountScoped.ConversationModule +import com.wire.android.di.accountScoped.DebugModule +import com.wire.android.di.accountScoped.MeetingModule +import com.wire.android.di.accountScoped.MessageModule +import com.wire.android.di.accountScoped.SearchModule +import com.wire.android.di.accountScoped.ServicesModule +import com.wire.android.di.accountScoped.TeamModule +import com.wire.android.di.accountScoped.UserModule +import com.wire.android.feature.cells.ui.CellsMetroViewModelBindings +import com.wire.android.feature.meetings.ui.MeetingsMetroViewModelBindings +import com.wire.android.feature.sketch.SketchMetroViewModelBindings +import com.wire.android.mediaplayer.MediaPlayerMetroViewModelBindings +import com.wire.android.search.SearchMetroViewModelBindings import com.wire.android.ui.authentication.AuthenticationViewModelGraph -import com.wire.android.ui.common.CommonViewModelGraph -import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel +import com.wire.android.ui.calling.CallingMetroViewModelBindings +import com.wire.android.ui.common.CommonMetroViewModelBindings +import com.wire.android.ui.common.CoreUICommonMetroViewModelBindings +import com.wire.android.ui.debug.DebugMetroViewModelBindings +import com.wire.android.ui.debug.DebugInfoViewModelGraph +import com.wire.android.ui.home.HomeMetroViewModelBindings import com.wire.android.ui.home.HomeViewModelGraph -import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel +import com.wire.android.ui.home.conversations.ConversationSearchFolderMetroViewModelBindings import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.user.UserId import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.GraphExtension import dev.zacsweers.metro.Provides -import dev.zacsweers.metro.Provider import dev.zacsweers.metro.Scope import dev.zacsweers.metro.asContribution import dev.zacsweers.metrox.viewmodel.ViewModelGraph @@ -39,13 +63,46 @@ import dev.zacsweers.metrox.viewmodel.ViewModelGraph @Scope annotation class MetroSessionScope -@GraphExtension(MetroSessionScope::class, bindingContainers = [WireMetroViewModelBindings::class, ImageLoadingModule::class]) +@GraphExtension( + MetroSessionScope::class, + bindingContainers = [ + SessionModule::class, + AppsModule::class, + AuthenticationModule::class, + BackupModule::class, + CallsModule::class, + CellsModule::class, + ChannelsModule::class, + ClientModule::class, + ConnectionModule::class, + ConversationModule::class, + DebugModule::class, + MessageModule::class, + SearchModule::class, + ServicesModule::class, + TeamModule::class, + UserModule::class, + MeetingModule::class, + WireMetroViewModelBindings::class, + DebugMetroViewModelBindings::class, + HomeMetroViewModelBindings::class, + ConversationSearchFolderMetroViewModelBindings::class, + CallingMetroViewModelBindings::class, + CommonMetroViewModelBindings::class, + CellsMetroViewModelBindings::class, + MeetingsMetroViewModelBindings::class, + SketchMetroViewModelBindings::class, + CoreUICommonMetroViewModelBindings::class, + SearchMetroViewModelBindings::class, + MediaPlayerMetroViewModelBindings::class, + ImageLoadingModule::class, + ] +) interface AppSessionViewModelGraph : ViewModelGraph, - MiscViewModelGraph, AuthenticationViewModelGraph, - HomeViewModelGraph, - CommonViewModelGraph { + DebugInfoViewModelGraph, + HomeViewModelGraph { @get:CurrentAccount val currentAccount: UserId @@ -54,9 +111,6 @@ interface AppSessionViewModelGraph : val wireSessionImageLoader: WireSessionImageLoader - override val featureFlagNotificationViewModel: Provider - override val commonTopAppBarViewModelFactory: CommonTopAppBarViewModel.Factory - @ContributesTo(AppScope::class) @GraphExtension.Factory interface Factory { diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AuthenticationMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/di/metro/AuthenticationMetroViewModelBindings.kt new file mode 100644 index 00000000000..c716a79602d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/di/metro/AuthenticationMetroViewModelBindings.kt @@ -0,0 +1,120 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.di.metro + +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.CreationExtras +import com.wire.android.ui.WireActivityViewModel +import com.wire.android.ui.authentication.AuthenticationManualViewModelFactory +import com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel +import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs +import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs +import com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel +import com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel +import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewNavArgs +import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel +import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryNavArgs +import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel +import com.wire.android.ui.authentication.login.LoginNavArgs +import com.wire.android.ui.authentication.login.email.LoginEmailViewModel +import com.wire.android.ui.authentication.login.sso.LoginSSOViewModel +import com.wire.android.ui.authentication.welcome.WelcomeNavArgs +import com.wire.android.ui.authentication.welcome.WelcomeViewModel +import com.wire.android.ui.newauthentication.login.NewLoginViewModel +import com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel +import com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel +import com.wire.android.ui.registration.selector.CreateAccountSelectorNavArgs +import com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +/** Bindings safe to instantiate before any authenticated session exists. */ +@BindingContainer +object AuthenticationMetroViewModelBindings { + + @Provides + @IntoMap + @ViewModelKey(WireActivityViewModel::class) + fun wireActivityViewModel(viewModel: WireActivityViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(AuthenticationManualViewModelFactory::class) + @Suppress("LongParameterList") + fun authenticationManualViewModelFactory( + welcomeFactory: WelcomeViewModel.Factory, + newLoginFactory: NewLoginViewModel.Factory, + loginEmailFactory: LoginEmailViewModel.Factory, + loginSSOFactory: LoginSSOViewModel.Factory, + createAccountOverviewFactory: CreateAccountOverviewViewModel.Factory, + createAccountEmailFactory: CreateAccountEmailViewModel.Factory, + createAccountDetailsFactory: CreateAccountDetailsViewModel.Factory, + createAccountCodeFactory: CreateAccountCodeViewModel.Factory, + createAccountSummaryFactory: CreateAccountSummaryViewModel.Factory, + createAccountSelectorFactory: CreateAccountSelectorViewModel.Factory, + createAccountDataDetailFactory: CreateAccountDataDetailViewModel.Factory, + createAccountVerificationCodeFactory: CreateAccountVerificationCodeViewModel.Factory, + ): ManualViewModelAssistedFactory = object : AuthenticationManualViewModelFactory { + override fun welcomeViewModel(navArgs: WelcomeNavArgs): WelcomeViewModel = + welcomeFactory.create(navArgs) + + override fun newLoginViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): NewLoginViewModel = + newLoginFactory.create(loginNavArgs, extras.createSavedStateHandle()) + + override fun loginEmailViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): LoginEmailViewModel = + loginEmailFactory.create(loginNavArgs, extras.createSavedStateHandle()) + + override fun loginSSOViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): LoginSSOViewModel = + loginSSOFactory.create(loginNavArgs, extras.createSavedStateHandle()) + + override fun createAccountOverviewViewModel( + navArgs: CreateAccountOverviewNavArgs, + ): CreateAccountOverviewViewModel = createAccountOverviewFactory.create(navArgs) + + override fun createAccountEmailViewModel(navArgs: CreateAccountNavArgs): CreateAccountEmailViewModel = + createAccountEmailFactory.create(navArgs) + + override fun createAccountDetailsViewModel(navArgs: CreateAccountNavArgs): CreateAccountDetailsViewModel = + createAccountDetailsFactory.create(navArgs) + + override fun createAccountCodeViewModel(navArgs: CreateAccountNavArgs): CreateAccountCodeViewModel = + createAccountCodeFactory.create(navArgs) + + override fun createAccountSummaryViewModel( + navArgs: CreateAccountSummaryNavArgs, + ): CreateAccountSummaryViewModel = createAccountSummaryFactory.create(navArgs) + + override fun createAccountSelectorViewModel( + navArgs: CreateAccountSelectorNavArgs, + ): CreateAccountSelectorViewModel = createAccountSelectorFactory.create(navArgs) + + override fun createAccountDataDetailViewModel( + navArgs: CreateAccountDataNavArgs, + ): CreateAccountDataDetailViewModel = createAccountDataDetailFactory.create(navArgs) + + override fun createAccountVerificationCodeViewModel( + navArgs: CreateAccountDataNavArgs, + ): CreateAccountVerificationCodeViewModel = + createAccountVerificationCodeFactory.create(navArgs) + } +} diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt index 2d4fa2cc075..c6236517c47 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt @@ -27,45 +27,22 @@ import com.wire.android.di.CoroutinesScopesModule import com.wire.android.di.KaliumConfigsModule import com.wire.android.di.LogWriterModule import com.wire.android.di.ManagedConfigurationsModule -import com.wire.android.di.SessionModule import com.wire.android.di.UseCaseModule -import com.wire.android.di.accountScoped.AppsModule -import com.wire.android.di.accountScoped.AuthenticationModule -import com.wire.android.di.accountScoped.BackupModule -import com.wire.android.di.accountScoped.CallsModule -import com.wire.android.di.accountScoped.CellsModule -import com.wire.android.di.accountScoped.ChannelsModule -import com.wire.android.di.accountScoped.ClientModule -import com.wire.android.di.accountScoped.ConnectionModule -import com.wire.android.di.accountScoped.ConversationModule -import com.wire.android.di.accountScoped.DebugModule -import com.wire.android.di.accountScoped.MeetingModule -import com.wire.android.di.accountScoped.MessageModule -import com.wire.android.di.accountScoped.SearchModule -import com.wire.android.di.accountScoped.ServicesModule -import com.wire.android.di.accountScoped.TeamModule -import com.wire.android.di.accountScoped.UserModule -import com.wire.android.feature.cells.ui.CellsMetroViewModelBindings -import com.wire.android.feature.meetings.ui.MeetingsMetroViewModelBindings -import com.wire.android.mediaplayer.MediaPlayerMetroViewModelBindings import com.wire.android.notification.broadcastreceivers.EndOngoingCallReceiver import com.wire.android.notification.broadcastreceivers.IncomingCallActionReceiver import com.wire.android.notification.broadcastreceivers.NomadLogoutReceiver import com.wire.android.notification.broadcastreceivers.NotificationReplyReceiver import com.wire.android.notification.broadcastreceivers.PlayPauseAudioMessageReceiver import com.wire.android.notification.broadcastreceivers.StopAudioMessageReceiver -import com.wire.android.search.SearchMetroViewModelBindings import com.wire.android.services.CallService import com.wire.android.services.PendingMessagesForegroundService import com.wire.android.services.PersistentWebSocketService import com.wire.android.services.PlayingAudioMessageService import com.wire.android.ui.AppLockActivity import com.wire.android.ui.WireActivity -import com.wire.android.ui.WireActivityViewModel import com.wire.android.ui.debug.StartServiceReceiver import com.wire.android.ui.calling.CallActivity import com.wire.android.ui.calling.ongoing.OngoingCallActivity -import com.wire.android.ui.common.CoreUICommonMetroViewModelBindings import com.wire.android.util.NetworkUtil import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.workmanager.WireWorkerFactory @@ -81,43 +58,19 @@ import dev.zacsweers.metrox.viewmodel.ViewModelGraph bindingContainers = [ AppModule::class, CoreLogicModule::class, - SessionModule::class, UseCaseModule::class, ManagedConfigurationsModule::class, KaliumConfigsModule::class, LogWriterModule::class, CoroutinesScopesModule::class, CoroutinesDispatchersModule::class, - AppsModule::class, - AuthenticationModule::class, - BackupModule::class, - CallsModule::class, - CellsModule::class, - ChannelsModule::class, - ClientModule::class, - ConnectionModule::class, - ConversationModule::class, - DebugModule::class, - MessageModule::class, - SearchModule::class, - ServicesModule::class, - TeamModule::class, - UserModule::class, - MeetingModule::class, - WireMetroViewModelBindings::class, - CellsMetroViewModelBindings::class, - MeetingsMetroViewModelBindings::class, - CoreUICommonMetroViewModelBindings::class, - SearchMetroViewModelBindings::class, - MediaPlayerMetroViewModelBindings::class, + AuthenticationMetroViewModelBindings::class, ] ) @Suppress("TooManyFunctions") interface WireApplicationGraph : ViewModelGraph { val wireWorkerFactory: WireWorkerFactory - val wireActivityViewModel: WireActivityViewModel val authenticationViewModelGraph: AppAuthenticationViewModelGraph - val imageAssetViewModelGraph: AppImageAssetViewModelGraph @get:KaliumCoreLogic val coreLogic: CoreLogic diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt index db538c669a5..9163428b028 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelBindings.kt @@ -20,119 +20,83 @@ package com.wire.android.di.metro import androidx.lifecycle.ViewModel -import androidx.lifecycle.createSavedStateHandle -import androidx.lifecycle.viewmodel.CreationExtras -import com.wire.android.ui.CallFeedbackViewModel -import com.wire.android.ui.debug.DebugDataOptionsViewModelImpl -import com.wire.android.ui.debug.ExportObfuscatedCopyViewModelImpl -import com.wire.android.ui.debug.LogManagementViewModel -import com.wire.android.ui.debug.UserDebugViewModel -import com.wire.android.ui.debug.conversation.DebugConversationViewModel -import com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsViewModel -import com.wire.android.ui.debug.featureflags.DebugFeatureFlagsViewModel import com.wire.android.ui.analytics.AnalyticsUsageViewModel -import com.wire.android.ui.authentication.AuthenticationManualViewModelFactory -import com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel -import com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel -import com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel -import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel -import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel +import com.wire.android.ui.authentication.SessionAuthenticationManualViewModelFactory +import com.wire.android.ui.authentication.SessionAuthenticationViewModelFactory import com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel import com.wire.android.ui.authentication.devices.common.ClearSessionViewModel import com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel import com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel -import com.wire.android.ui.authentication.login.LoginNavArgs -import com.wire.android.ui.authentication.login.email.LoginEmailViewModel -import com.wire.android.ui.authentication.login.sso.LoginSSOViewModel -import com.wire.android.ui.authentication.welcome.WelcomeViewModel -import com.wire.android.ui.calling.CallingManualViewModelFactory -import com.wire.android.ui.calling.common.SharedCallingViewModel -import com.wire.android.ui.calling.incoming.IncomingCallViewModel -import com.wire.android.ui.calling.ongoing.OngoingCallViewModel -import com.wire.android.ui.calling.outgoing.OutgoingCallViewModel -import com.wire.android.ui.common.CommonManualViewModelFactory import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentViewModel import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateViewModel -import com.wire.android.ui.home.AppSyncViewModel -import com.wire.android.ui.home.HomeManualViewModelFactory -import com.wire.android.ui.home.HomeViewModel import com.wire.android.ui.home.appLock.forgot.ForgotLockScreenViewModel import com.wire.android.ui.home.appLock.set.SetLockScreenViewModel import com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsViewModel import com.wire.android.ui.home.appLock.unlock.EnterLockScreenViewModel import com.wire.android.ui.home.conversations.ConversationCoreManualViewModelFactory -import com.wire.android.ui.home.conversations.ConversationSearchFolderManualViewModelFactory +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.ConversationDetailsManualViewModelFactory import com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel import com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel -import com.wire.android.ui.home.conversations.call.ConversationCallViewModel import com.wire.android.ui.home.conversations.composer.MessageComposerViewModel +import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavArgs import com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel +import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessNavArgs import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessViewModel +import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkNavArgs import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel +import com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesNavArgs import com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesViewModel import com.wire.android.ui.home.conversations.details.metadata.EditConversationMetadataViewModel +import com.wire.android.ui.home.conversations.details.metadata.EditConversationNameNavArgs +import com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsNavArgs import com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsViewModel +import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessNavArgs import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessViewModel +import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessArgs import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessViewModel -import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs -import com.wire.android.ui.home.conversations.folder.ConversationFoldersVMImpl -import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderArgs -import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderVMImpl -import com.wire.android.ui.home.conversations.folder.NewFolderViewModel -import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel import com.wire.android.ui.home.conversations.media.ConversationAssetMessagesViewModel +import com.wire.android.ui.home.conversations.media.ConversationMediaNavArgs +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavArgs import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewViewModel +import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsNavArgs import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.QuotedMultipartMessageViewModel import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel import com.wire.android.ui.home.conversations.messages.item.ConversationAssetPathsViewModelImpl -import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel import com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsViewModelImpl -import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel -import com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel -import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel +import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel import com.wire.android.ui.home.conversations.ScopedMessageManualViewModelFactory -import com.wire.android.ui.home.conversationslist.ConversationListCallViewModelImpl -import com.wire.android.ui.home.conversationslist.ConversationListViewModelImpl -import com.wire.android.ui.home.conversationslist.model.ConversationsSource -import com.wire.android.ui.home.drawer.HomeDrawerViewModel +import com.wire.android.ui.home.gallery.MediaGalleryNavArgs import com.wire.android.ui.home.gallery.MediaGalleryViewModel import com.wire.android.ui.home.messagecomposer.location.LocationPickerViewModel -import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.settings.SettingsViewModel +import com.wire.android.ui.home.settings.SettingsManualViewModelFactory import com.wire.android.ui.home.settings.account.MyAccountViewModel import com.wire.android.ui.home.settings.account.color.ChangeUserColorViewModel import com.wire.android.ui.home.settings.account.deleteAccount.DeleteAccountViewModel import com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameViewModel import com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailViewModel import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel +import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModelArgs import com.wire.android.ui.home.settings.account.handle.ChangeHandleViewModel import com.wire.android.ui.home.settings.appearance.CustomizationViewModel import com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsViewModel import com.wire.android.ui.home.settings.backup.BackupAndRestoreViewModel -import com.wire.android.ui.home.settings.about.dependencies.DependenciesViewModel -import com.wire.android.ui.home.settings.about.licenses.LicensesViewModel import com.wire.android.ui.home.settings.privacy.PrivacySettingsViewModel -import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel -import com.wire.android.ui.home.whatsnew.WhatsNewViewModel import com.wire.android.ui.initialsync.InitialSyncViewModel import com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel -import com.wire.android.ui.newauthentication.login.NewLoginViewModel -import com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel -import com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel -import com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel -import com.wire.android.ui.settings.about.AboutThisAppViewModel -import com.wire.android.ui.settings.devices.DeviceDetailsViewModel +import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedViewModel import com.wire.android.ui.settings.devices.SelfDevicesViewModel +import com.wire.android.ui.settings.devices.DeviceDetailsViewModel +import com.wire.android.ui.settings.devices.DeviceDetailsViewModelArgs import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModelArgs import com.wire.android.ui.sharing.ImportMediaAuthenticatedViewModel -import com.wire.android.ui.common.banner.SecurityClassificationArgs -import com.wire.android.ui.common.banner.SecurityClassificationViewModelImpl -import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionsMenuViewModelImpl import com.wire.android.media.audiomessage.AudioMessageArgs import com.wire.android.media.audiomessage.AudioMessageViewModelImpl import com.wire.android.ui.home.conversations.CompositeMessageViewModelImpl @@ -143,15 +107,17 @@ import com.wire.android.ui.home.conversations.messages.item.AssetLocalPathViewMo import com.wire.android.ui.home.conversations.model.CompositeMessageArgs import com.wire.android.ui.home.conversations.typing.TypingIndicatorArgs import com.wire.android.ui.home.conversations.typing.TypingIndicatorViewModelImpl -import com.wire.android.ui.home.meetings.MeetingsCallViewModel import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionArgs import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModelImpl import com.wire.android.ui.home.messagecomposer.attachments.IsFileSharingEnabledViewModelImpl import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioViewModel import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel +import com.wire.android.ui.userprofile.other.OtherUserProfileViewModelArgs import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel +import com.wire.android.ui.userprofile.qr.SelfQrCodeViewModelArgs import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel +import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelArgs import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel import com.wire.kalium.logic.data.id.ConversationId @@ -162,120 +128,11 @@ import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provider import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey -import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory -import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import dev.zacsweers.metrox.viewmodel.ViewModelKey @BindingContainer object WireMetroViewModelBindings { - @Provides - @IntoMap - @ViewModelKey(AppSyncViewModel::class) - fun appSyncViewModel(viewModel: AppSyncViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(FeatureFlagNotificationViewModel::class) - fun featureFlagNotificationViewModel(viewModel: FeatureFlagNotificationViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(HomeViewModel::class) - fun homeViewModel(factory: HomeViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(HomeDrawerViewModel::class) - fun homeDrawerViewModel(factory: HomeDrawerViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelKey(NewConversationViewModel::class) - fun newConversationViewModel(viewModel: NewConversationViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(CallFeedbackViewModel::class) - fun callFeedbackViewModel(viewModel: CallFeedbackViewModel): ViewModel = - viewModel - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ConversationCallViewModel::class) - fun conversationCallViewModel(factory: ConversationCallViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelKey(MeetingsCallViewModel::class) - fun joinOrStartCallViewModel(viewModel: MeetingsCallViewModel): ViewModel = - viewModel - - @Provides - @IntoMap - @ViewModelKey(ConversationListCallViewModelImpl::class) - fun conversationListCallViewModel(viewModel: ConversationListCallViewModelImpl): ViewModel = - viewModel - - @Provides - @IntoMap - @ManualViewModelAssistedFactoryKey(CallingManualViewModelFactory::class) - fun callingManualViewModelFactory( - incomingCallFactory: IncomingCallViewModel.Factory, - outgoingCallFactory: OutgoingCallViewModel.Factory, - ongoingCallFactory: OngoingCallViewModel.Factory, - sharedCallingFactory: SharedCallingViewModel.Factory, - ): ManualViewModelAssistedFactory = - object : CallingManualViewModelFactory { - override fun incomingCallViewModel(conversationId: ConversationId): IncomingCallViewModel = - incomingCallFactory.create(conversationId) - - override fun outgoingCallViewModel(conversationId: ConversationId): OutgoingCallViewModel = - outgoingCallFactory.create(conversationId) - - override fun ongoingCallViewModel(conversationId: ConversationId): OngoingCallViewModel = - ongoingCallFactory.create(conversationId) - - override fun sharedCallingViewModel(conversationId: ConversationId): SharedCallingViewModel = - sharedCallingFactory.create(conversationId) - } - - @Provides - @IntoMap - @ManualViewModelAssistedFactoryKey(HomeManualViewModelFactory::class) - fun homeManualViewModelFactory(factory: ConversationListViewModelImpl.Factory): ManualViewModelAssistedFactory = - object : HomeManualViewModelFactory { - override fun conversationListViewModel(conversationsSource: ConversationsSource): ConversationListViewModelImpl = - factory.create(conversationsSource) - } - - @Provides - @IntoMap - @ManualViewModelAssistedFactoryKey(CommonManualViewModelFactory::class) - fun commonManualViewModelFactory( - securityClassificationFactory: SecurityClassificationViewModelImpl.Factory, - conversationOptionsMenuProvider: Provider, - ): ManualViewModelAssistedFactory = - object : CommonManualViewModelFactory { - override fun securityClassificationViewModel(args: SecurityClassificationArgs): SecurityClassificationViewModelImpl = - securityClassificationFactory.create(args) - - override fun conversationOptionsMenuViewModel(): ConversationOptionsMenuViewModelImpl = - conversationOptionsMenuProvider() - } - @Provides @IntoMap @ManualViewModelAssistedFactoryKey(ScopedMessageManualViewModelFactory::class) @@ -292,10 +149,9 @@ object WireMetroViewModelBindings { ): ManualViewModelAssistedFactory = object : ScopedMessageManualViewModelFactory { override fun compositeMessageViewModel( - savedStateHandle: androidx.lifecycle.SavedStateHandle, args: CompositeMessageArgs ): CompositeMessageViewModelImpl = - compositeMessageFactory.create(savedStateHandle, args) + compositeMessageFactory.create(args) override fun messageOptionsMenuViewModel(args: MessageOptionsMenuArgs): MessageOptionsMenuViewModelImpl = messageOptionsMenuFactory.create(args) @@ -321,65 +177,6 @@ object WireMetroViewModelBindings { audioMessageFactory.create(args) } - @Provides - @IntoMap - @ViewModelKey(UserDebugViewModel::class) - fun userDebugViewModel(viewModel: UserDebugViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(LogManagementViewModel::class) - fun logManagementViewModel(viewModel: LogManagementViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(DebugDataOptionsViewModelImpl::class) - fun debugDataOptionsViewModel(viewModel: DebugDataOptionsViewModelImpl): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(ExportObfuscatedCopyViewModelImpl::class) - fun exportObfuscatedCopyViewModel(viewModel: ExportObfuscatedCopyViewModelImpl): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(DebugConversationViewModel::class) - fun debugConversationViewModel(factory: DebugConversationViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelKey(ConversationCryptoStatsViewModel::class) - fun conversationCryptoStatsViewModel(viewModel: ConversationCryptoStatsViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(DebugFeatureFlagsViewModel::class) - fun debugFeatureFlagsViewModel(viewModel: DebugFeatureFlagsViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(WhatsNewViewModel::class) - fun whatsNewViewModel(viewModel: WhatsNewViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(AboutThisAppViewModel::class) - fun aboutThisAppViewModel(viewModel: AboutThisAppViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(DependenciesViewModel::class) - fun dependenciesViewModel(viewModel: DependenciesViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelKey(LicensesViewModel::class) - fun licensesViewModel(viewModel: LicensesViewModel): ViewModel = viewModel - @Provides @IntoMap @ViewModelKey(AnalyticsUsageViewModel::class) @@ -395,6 +192,11 @@ object WireMetroViewModelBindings { @ViewModelKey(LegalHoldRequestedViewModel::class) fun legalHoldRequestedViewModel(viewModel: LegalHoldRequestedViewModel): ViewModel = viewModel + @Provides + @IntoMap + @ViewModelKey(LegalHoldDeactivatedViewModel::class) + fun legalHoldDeactivatedViewModel(viewModel: LegalHoldDeactivatedViewModel): ViewModel = viewModel + @Provides @IntoMap @ViewModelKey(E2EIEnrollmentViewModel::class) @@ -405,15 +207,6 @@ object WireMetroViewModelBindings { @ViewModelKey(GetE2EICertificateViewModel::class) fun getE2EICertificateViewModel(viewModel: GetE2EICertificateViewModel): ViewModel = viewModel - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(E2eiCertificateDetailsViewModel::class) - fun e2eiCertificateDetailsViewModel(factory: E2eiCertificateDetailsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - @Provides @IntoMap @ViewModelKey(ImportMediaAuthenticatedViewModel::class) @@ -424,388 +217,126 @@ object WireMetroViewModelBindings { @ViewModelKey(JoinConversationViaCodeViewModel::class) fun joinConversationViaCodeViewModel(viewModel: JoinConversationViaCodeViewModel): ViewModel = viewModel - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(WelcomeViewModel::class) - fun welcomeViewModel(factory: WelcomeViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(NewLoginViewModel::class) - fun newLoginViewModel(factory: NewLoginViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - @Provides @IntoMap @ViewModelKey(RegisterDeviceViewModel::class) - fun registerDeviceViewModel(viewModel: RegisterDeviceViewModel): ViewModel = viewModel + fun registerDeviceViewModel(factory: SessionAuthenticationViewModelFactory): ViewModel = + factory.registerDeviceViewModel() @Provides @IntoMap @ViewModelKey(RemoveDeviceViewModel::class) - fun removeDeviceViewModel(viewModel: RemoveDeviceViewModel): ViewModel = viewModel + fun removeDeviceViewModel(factory: SessionAuthenticationViewModelFactory): ViewModel = + factory.removeDeviceViewModel() @Provides @IntoMap - @ManualViewModelAssistedFactoryKey(AuthenticationManualViewModelFactory::class) - fun authenticationManualViewModelFactory( - loginEmailFactory: LoginEmailViewModel.Factory, - loginSSOFactory: LoginSSOViewModel.Factory, - clearSessionFactory: ClearSessionViewModel.Factory, - ): ManualViewModelAssistedFactory = - object : AuthenticationManualViewModelFactory { - override fun loginEmailViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): LoginEmailViewModel = - loginEmailFactory.create(loginNavArgs, extras.createSavedStateHandle()) - - override fun loginSSOViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): LoginSSOViewModel = - loginSSOFactory.create(loginNavArgs, extras.createSavedStateHandle()) - - override fun clearSessionViewModel(cancelUserId: UserId?): ClearSessionViewModel = - clearSessionFactory.create(cancelUserId) - } + @ManualViewModelAssistedFactoryKey(SessionAuthenticationManualViewModelFactory::class) + fun sessionAuthenticationManualViewModelFactory( + factory: SessionAuthenticationViewModelFactory, + ): ManualViewModelAssistedFactory = object : SessionAuthenticationManualViewModelFactory { + override fun clearSessionViewModel(cancelUserId: UserId?): ClearSessionViewModel = + factory.clearSessionViewModel(cancelUserId) + } @Provides @IntoMap @ViewModelKey(CreateAccountUsernameViewModel::class) - fun createAccountUsernameViewModel(viewModel: CreateAccountUsernameViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountOverviewViewModel::class) - fun createAccountOverviewViewModel(factory: CreateAccountOverviewViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountEmailViewModel::class) - fun createAccountEmailViewModel(factory: CreateAccountEmailViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountDetailsViewModel::class) - fun createAccountDetailsViewModel(factory: CreateAccountDetailsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountCodeViewModel::class) - fun createAccountCodeViewModel(factory: CreateAccountCodeViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountSummaryViewModel::class) - fun createAccountSummaryViewModel(factory: CreateAccountSummaryViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountSelectorViewModel::class) - fun createAccountSelectorViewModel(factory: CreateAccountSelectorViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountDataDetailViewModel::class) - fun createAccountDataDetailViewModel(factory: CreateAccountDataDetailViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateAccountVerificationCodeViewModel::class) - fun createAccountVerificationCodeViewModel(factory: CreateAccountVerificationCodeViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ConversationMessagesViewModel::class) - fun conversationMessagesViewModel(factory: ConversationMessagesViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(MessageComposerViewModel::class) - fun messageComposerViewModel(factory: MessageComposerViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(SendMessageViewModel::class) - fun sendMessageViewModel(factory: SendMessageViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(MessageDraftViewModel::class) - fun messageDraftViewModel(factory: MessageDraftViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(MessageAttachmentsViewModel::class) - fun messageAttachmentsViewModel(factory: MessageAttachmentsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ConversationMigrationViewModel::class) - fun conversationMigrationViewModel(factory: ConversationMigrationViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } + fun createAccountUsernameViewModel(factory: SessionAuthenticationViewModelFactory): ViewModel = + factory.createAccountUsernameViewModel() @Provides @IntoMap @ViewModelKey(ConversationAssetPathsViewModelImpl::class) fun conversationAssetPathsViewModel(viewModel: ConversationAssetPathsViewModelImpl): ViewModel = viewModel - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(MediaGalleryViewModel::class) - fun mediaGalleryViewModel(factory: MediaGalleryViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - @Provides @IntoMap @ViewModelKey(LocationPickerViewModel::class) fun locationPickerViewModel(viewModel: LocationPickerViewModel): ViewModel = viewModel - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ConversationAssetMessagesViewModel::class) - fun conversationAssetMessagesViewModel(factory: ConversationAssetMessagesViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ImagesPreviewViewModel::class) - fun imagesPreviewViewModel(factory: ImagesPreviewViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(MessageDetailsViewModel::class) - fun messageDetailsViewModel(factory: MessageDetailsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - @Provides @IntoMap @ViewModelKey(QuotedMultipartMessageViewModel::class) fun quotedMultipartMessageViewModel(viewModel: QuotedMultipartMessageViewModel): ViewModel = viewModel - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ConversationBannerViewModel::class) - fun conversationBannerViewModel(factory: ConversationBannerViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ConversationInfoViewModel::class) - fun conversationInfoViewModel(factory: ConversationInfoViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - @Provides @IntoMap @ManualViewModelAssistedFactoryKey(ConversationCoreManualViewModelFactory::class) + @Suppress("LongParameterList") fun conversationCoreManualViewModelFactory( - factory: MultipartAttachmentsViewModelImpl.Factory + conversationMessagesFactory: ConversationMessagesViewModel.Factory, + messageComposerFactory: MessageComposerViewModel.Factory, + sendMessageFactory: SendMessageViewModel.Factory, + messageDraftFactory: MessageDraftViewModel.Factory, + messageAttachmentsFactory: MessageAttachmentsViewModel.Factory, + conversationMigrationFactory: ConversationMigrationViewModel.Factory, + conversationBannerFactory: ConversationBannerViewModel.Factory, + conversationInfoFactory: com.wire.android.ui.home.conversations.info.ConversationInfoViewModel.Factory, + multipartAttachmentsFactory: MultipartAttachmentsViewModelImpl.Factory, + mediaGalleryFactory: MediaGalleryViewModel.Factory, + conversationAssetMessagesFactory: ConversationAssetMessagesViewModel.Factory, + imagesPreviewFactory: ImagesPreviewViewModel.Factory, + messageDetailsFactory: MessageDetailsViewModel.Factory, ): ManualViewModelAssistedFactory = object : ConversationCoreManualViewModelFactory { + override fun conversationMessagesViewModel(args: ConversationNavArgs) = + conversationMessagesFactory.create(args) + override fun messageComposerViewModel(args: ConversationNavArgs) = + messageComposerFactory.create(args) + override fun sendMessageViewModel(args: ConversationNavArgs) = + sendMessageFactory.create(args) + override fun messageDraftViewModel(args: ConversationNavArgs) = + messageDraftFactory.create(args) + override fun messageAttachmentsViewModel(args: ConversationNavArgs) = + messageAttachmentsFactory.create(args) + override fun conversationMigrationViewModel(args: ConversationNavArgs) = + conversationMigrationFactory.create(args) + override fun conversationBannerViewModel(args: ConversationNavArgs) = + conversationBannerFactory.create(args) + override fun conversationInfoViewModel(args: ConversationNavArgs) = + conversationInfoFactory.create(args) override fun multipartAttachmentsViewModel(conversationId: ConversationId): MultipartAttachmentsViewModelImpl = - factory.create(conversationId) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(GroupConversationDetailsViewModel::class) - fun groupConversationDetailsViewModel(factory: GroupConversationDetailsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) + multipartAttachmentsFactory.create(conversationId) + override fun mediaGalleryViewModel(args: MediaGalleryNavArgs) = mediaGalleryFactory.create(args) + override fun conversationAssetMessagesViewModel(args: ConversationMediaNavArgs) = + conversationAssetMessagesFactory.create(args) + override fun imagesPreviewViewModel(args: ImagesPreviewNavArgs) = imagesPreviewFactory.create(args) + override fun messageDetailsViewModel(args: MessageDetailsNavArgs) = messageDetailsFactory.create(args) } @Provides @IntoMap - @ViewModelAssistedFactoryKey(GroupConversationParticipantsViewModel::class) - fun groupConversationParticipantsViewModel(factory: GroupConversationParticipantsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(EditConversationMetadataViewModel::class) - fun editConversationMetadataViewModel(factory: EditConversationMetadataViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(EditSelfDeletingMessagesViewModel::class) - fun editSelfDeletingMessagesViewModel(factory: EditSelfDeletingMessagesViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(UpdateChannelAccessViewModel::class) - fun updateChannelAccessViewModel(factory: UpdateChannelAccessViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(UpdateAppsAccessViewModel::class) - fun updateAppsAccessViewModel(factory: UpdateAppsAccessViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(EditGuestAccessViewModel::class) - fun editGuestAccessViewModel(factory: EditGuestAccessViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreatePasswordGuestLinkViewModel::class) - fun createPasswordGuestLinkViewModel(factory: CreatePasswordGuestLinkViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } + @ManualViewModelAssistedFactoryKey(ConversationDetailsManualViewModelFactory::class) + @Suppress("LongParameterList") + fun conversationDetailsManualViewModelFactory( + groupDetailsFactory: GroupConversationDetailsViewModel.Factory, + participantsFactory: GroupConversationParticipantsViewModel.Factory, + metadataFactory: EditConversationMetadataViewModel.Factory, + selfDeletingFactory: EditSelfDeletingMessagesViewModel.Factory, + channelAccessFactory: UpdateChannelAccessViewModel.Factory, + appsAccessFactory: UpdateAppsAccessViewModel.Factory, + guestAccessFactory: EditGuestAccessViewModel.Factory, + guestLinkFactory: CreatePasswordGuestLinkViewModel.Factory, + ): ManualViewModelAssistedFactory = object : ConversationDetailsManualViewModelFactory { + override fun groupConversationDetailsViewModel(args: GroupConversationDetailsNavArgs) = + groupDetailsFactory.create(args) + override fun groupConversationParticipantsViewModel(args: GroupConversationAllParticipantsNavArgs) = + participantsFactory.create(args) + override fun editConversationMetadataViewModel(args: EditConversationNameNavArgs) = metadataFactory.create(args) + override fun editSelfDeletingMessagesViewModel(args: EditSelfDeletingMessagesNavArgs) = + selfDeletingFactory.create(args) + override fun updateChannelAccessViewModel(args: UpdateChannelAccessArgs) = channelAccessFactory.create(args) + override fun updateAppsAccessViewModel(args: UpdateAppsAccessNavArgs) = appsAccessFactory.create(args) + override fun editGuestAccessViewModel(args: EditGuestAccessNavArgs) = guestAccessFactory.create(args) + override fun createPasswordGuestLinkViewModel(args: CreatePasswordGuestLinkNavArgs) = guestLinkFactory.create(args) + } @Provides @IntoMap @ViewModelKey(CheckAssetRestrictionsViewModel::class) fun checkAssetRestrictionsViewModel(viewModel: CheckAssetRestrictionsViewModel): ViewModel = viewModel - @Provides - @IntoMap - @ManualViewModelAssistedFactoryKey(ConversationSearchFolderManualViewModelFactory::class) - fun conversationSearchFolderManualViewModelFactory( - conversationFoldersFactory: ConversationFoldersVMImpl.Factory, - moveConversationToFolderFactory: MoveConversationToFolderVMImpl.Factory, - ): ManualViewModelAssistedFactory = - object : ConversationSearchFolderManualViewModelFactory { - override fun conversationFoldersViewModel(args: ConversationFoldersStateArgs): ConversationFoldersVMImpl = - conversationFoldersFactory.create(args) - - override fun moveConversationToFolderViewModel(args: MoveConversationToFolderArgs): MoveConversationToFolderVMImpl = - moveConversationToFolderFactory.create(args) - } - - @Provides - @IntoMap - @ViewModelKey(NewFolderViewModel::class) - fun newFolderViewModel(viewModel: NewFolderViewModel): ViewModel = viewModel - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(AddMembersToConversationViewModel::class) - fun addMembersToConversationViewModel(factory: AddMembersToConversationViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(SearchConversationMessagesViewModel::class) - fun searchConversationMessagesViewModel(factory: SearchConversationMessagesViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(PromoteAdminViewModel::class) - fun promoteAdminViewModel(factory: PromoteAdminViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - @Provides @IntoMap @ViewModelKey(SettingsViewModel::class) @@ -903,46 +434,23 @@ object WireMetroViewModelBindings { @Provides @IntoMap - @ViewModelAssistedFactoryKey(VerifyEmailViewModel::class) - fun verifyEmailViewModel(factory: VerifyEmailViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(DeviceDetailsViewModel::class) - fun deviceDetailsViewModel(factory: DeviceDetailsViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(SelfQRCodeViewModel::class) - fun selfQRCodeViewModel(factory: SelfQRCodeViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(OtherUserProfileScreenViewModel::class) - fun otherUserProfileScreenViewModel(factory: OtherUserProfileScreenViewModel.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(ServiceDetailsViewModelImpl::class) - fun serviceDetailsViewModel(factory: ServiceDetailsViewModelImpl.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } + @ManualViewModelAssistedFactoryKey(SettingsManualViewModelFactory::class) + @Suppress("LongParameterList") + fun settingsManualViewModelFactory( + verifyEmailFactory: VerifyEmailViewModel.Factory, + deviceDetailsFactory: DeviceDetailsViewModel.Factory, + certificateDetailsFactory: E2eiCertificateDetailsViewModel.Factory, + selfQrCodeFactory: SelfQRCodeViewModel.Factory, + otherUserProfileFactory: OtherUserProfileScreenViewModel.Factory, + serviceDetailsFactory: ServiceDetailsViewModelImpl.Factory, + ): ManualViewModelAssistedFactory = object : SettingsManualViewModelFactory { + override fun verifyEmailViewModel(arguments: VerifyEmailViewModelArgs) = verifyEmailFactory.create(arguments) + override fun deviceDetailsViewModel(arguments: DeviceDetailsViewModelArgs) = deviceDetailsFactory.create(arguments) + override fun e2eiCertificateDetailsViewModel(arguments: E2eiCertificateDetailsViewModelArgs) = + certificateDetailsFactory.create(arguments) + override fun selfQRCodeViewModel(arguments: SelfQrCodeViewModelArgs) = selfQrCodeFactory.create(arguments) + override fun otherUserProfileScreenViewModel(arguments: OtherUserProfileViewModelArgs) = + otherUserProfileFactory.create(arguments) + override fun serviceDetailsViewModel(arguments: ServiceDetailsViewModelArgs) = serviceDetailsFactory.create(arguments) + } } diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelFactory.kt index 5a082108baf..9238a2e692b 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModelFactory.kt @@ -26,14 +26,60 @@ import dev.zacsweers.metro.binding import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import com.wire.android.navigation.runtime.WireNavigationDiagnostics import kotlin.reflect.KClass +abstract class WireMetroViewModelFactory( + viewModelProviders: Map, () -> ViewModel>, + assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + manualAssistedFactoryProviders: + Map, () -> ManualViewModelAssistedFactory>, +) : MetroViewModelFactory() { + override val viewModelProviders = viewModelProviders.withDiagnostics("direct") + override val assistedFactoryProviders = assistedFactoryProviders.withDiagnostics("assisted") + override val manualAssistedFactoryProviders = + manualAssistedFactoryProviders.withDiagnostics("manual-assisted") +} + +/** Application graph factory. It must only see application-owned ViewModel bindings. */ @Inject @SingleIn(AppScope::class) @ContributesBinding(AppScope::class, binding = binding()) -class WireMetroViewModelFactory( - override val viewModelProviders: Map, () -> ViewModel>, - override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, - override val manualAssistedFactoryProviders: +class WireApplicationMetroViewModelFactory( + viewModelProviders: Map, () -> ViewModel>, + assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + manualAssistedFactoryProviders: + Map, () -> ManualViewModelAssistedFactory>, +) : WireMetroViewModelFactory( + viewModelProviders, + assistedFactoryProviders, + manualAssistedFactoryProviders, +) + +/** Session graph factory. It must be built from the selected session graph's binding maps. */ +@Inject +@SingleIn(MetroSessionScope::class) +@ContributesBinding(MetroSessionScope::class, binding = binding()) +class WireSessionMetroViewModelFactory( + viewModelProviders: Map, () -> ViewModel>, + assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, + manualAssistedFactoryProviders: Map, () -> ManualViewModelAssistedFactory>, -) : MetroViewModelFactory() +) : WireMetroViewModelFactory( + viewModelProviders, + assistedFactoryProviders, + manualAssistedFactoryProviders, +) + +private fun Map, () -> V>.withDiagnostics( + factory: String, +): Map, () -> V> = + mapValues { (type, provider) -> + { + WireNavigationDiagnostics.viewModel( + type = type.qualifiedName ?: type.simpleName ?: "anonymous", + factory = factory, + ) + provider() + } + } diff --git a/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt index 985a906dd3f..42ae0d9d66d 100644 --- a/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt @@ -20,11 +20,6 @@ package com.wire.android.feature import com.wire.android.appLogger import com.wire.android.di.ApplicationScope -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.UserId @@ -203,13 +198,3 @@ interface SwitchAccountActions { fun switchedToAnotherAccount() fun noOtherAccountToSwitch() } - -class NavigationSwitchAccountActions(val navigate: (NavigationCommand) -> Unit, val canUseNewLogin: () -> Boolean) : SwitchAccountActions { - override fun switchedToAnotherAccount() = navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) - override fun noOtherAccountToSwitch() = navigate( - NavigationCommand( - if (canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination(), - BackStackMode.CLEAR_WHOLE - ) - ) -} diff --git a/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt b/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt deleted file mode 100644 index ec7977926ae..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/FolderNavArgs.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class FolderNavArgs( - val folderId: String, - val folderName: String -) : Parcelable diff --git a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt index 5ccf20a12d6..474b54d52fa 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -20,15 +20,7 @@ package com.wire.android.navigation import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R -import com.ramcosta.composedestinations.generated.app.destinations.AllConversationsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ArchiveScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.GlobalCellsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MeetingsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SettingsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.VaultScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WhatsNewScreenDestination import com.wire.android.util.ui.UIText @Suppress("LongParameterList") @@ -36,7 +28,6 @@ sealed class HomeDestination( val title: UIText, @DrawableRes val icon: Int, val withUserAvatar: Boolean = true, - val direction: Direction, val searchBar: SearchBarOptions? = null, val fab: FabOptions? = null, val filterAction: FilterActionOptions? = null, @@ -47,57 +38,48 @@ sealed class HomeDestination( searchBar = SearchBarOptions(), fab = FabOptions.NewConversation, filterAction = FilterActionOptions.FilterConversations, - direction = AllConversationsScreenDestination ) data object Settings : HomeDestination( title = UIText.StringResource(R.string.settings_screen_title), icon = R.drawable.ic_settings, withUserAvatar = false, - direction = SettingsScreenDestination ) data object Vault : HomeDestination( title = UIText.StringResource(R.string.vault_screen_title), icon = R.drawable.ic_vault, - direction = VaultScreenDestination ) data object Archive : HomeDestination( title = UIText.StringResource(R.string.archive_screen_title), icon = R.drawable.ic_archive, searchBar = SearchBarOptions(), - direction = ArchiveScreenDestination ) data object Support : HomeDestination( title = UIText.StringResource(R.string.support_screen_title), icon = R.drawable.ic_support, - direction = SupportScreenDestination ) data object WhatsNew : HomeDestination( title = UIText.StringResource(R.string.whats_new_screen_title), icon = R.drawable.ic_star, - direction = WhatsNewScreenDestination ) data object TeamManagement : HomeDestination( title = UIText.StringResource(R.string.team_management_screen_title), icon = R.drawable.ic_team_management, - direction = TeamManagementScreenDestination ) data object Cells : HomeDestination( title = UIText.StringResource(R.string.drive_screen_title), icon = R.drawable.ic_files, - direction = GlobalCellsScreenDestination ) data object Meetings : HomeDestination( title = UIText.StringResource(R.string.meetings_screen_title), icon = com.wire.android.ui.common.R.drawable.ic_video_call, - direction = MeetingsScreenDestination, fab = FabOptions.NewMeeting, ) @@ -138,9 +120,6 @@ sealed class HomeDestination( companion object { private const val ITEM_NAME_PREFIX = "HomeNavigationItem." - fun fromRoute(fullRoute: String): HomeDestination? = - values().find { it.direction.baseRoute == fullRoute.getBaseRoute() } - fun values(): Array = arrayOf(Conversations, Settings, Vault, Archive, Support, TeamManagement, WhatsNew, Cells, Meetings) } diff --git a/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt b/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt deleted file mode 100644 index 0ca68c3d17a..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation - -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginVerificationCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.navArgs -import com.ramcosta.composedestinations.generated.app.navgraphs.NewConversationGraph -import com.ramcosta.composedestinations.generated.app.navgraphs.PersonalToTeamMigrationGraph -import com.ramcosta.composedestinations.generated.app.navgraphs.WireRootGraph -import com.ramcosta.composedestinations.generated.app.navtype.groupConversationDetailsNavBackArgsNavType -import com.ramcosta.composedestinations.generated.app.navtype.imagesPreviewNavBackArgsNavType -import com.ramcosta.composedestinations.generated.app.navtype.mediaGalleryNavBackArgsNavType -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination -import com.ramcosta.composedestinations.generated.meetings.navgraphs.NewMeetingGraph -import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination -import com.ramcosta.composedestinations.generated.sketch.navtype.drawingCanvasNavBackArgsNavType -import com.ramcosta.composedestinations.manualcomposablecalls.composable -import com.ramcosta.composedestinations.navigation.dependency -import com.ramcosta.composedestinations.navigation.destination -import com.ramcosta.composedestinations.navigation.navGraph -import com.ramcosta.composedestinations.scope.resultBackNavigator -import com.ramcosta.composedestinations.scope.resultRecipient -import com.ramcosta.composedestinations.spec.Direction -import com.wire.android.feature.cells.ui.cellViewModel -import com.wire.android.feature.meetings.navigation.MeetingNavigator -import com.wire.android.feature.meetings.ui.newMeetingViewModel -import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs -import com.wire.android.navigation.transition.LocalSharedTransitionScope -import com.wire.android.ui.authentication.loginEmailViewModel -import com.wire.android.ui.home.conversations.ConversationScreen -import com.wire.android.ui.home.newConversationViewModel -import com.wire.android.ui.home.settings.teamMigrationViewModel -import com.wire.kalium.logic.data.user.UserId - -@OptIn(ExperimentalAnimationApi::class, ExperimentalSharedTransitionApi::class) -@Composable -fun MainNavHost( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector?, - startDestination: Direction, - modifier: Modifier = Modifier, -) { - val navHostEngine = rememberWireNavHostEngine(Alignment.Center) - val meetingNavigator = remember(navigator) { - MeetingNavigator( - navigator = navigator, - navigateToProfile = { userId: UserId -> - navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(userId = userId))) - } - ) - } - SharedTransitionLayout(modifier = modifier) { - CompositionLocalProvider(LocalSharedTransitionScope provides this) { - DestinationsNavHost( - modifier = Modifier, - navGraph = WireRootGraph, - defaultTransitions = WireRootGraph.defaultTransitions, - engine = navHostEngine, - start = startDestination, - navController = navigator.navController, - dependenciesContainerBuilder = { - // 👇 To make Navigator available to all destinations as a non-navigation parameter - dependency(navigator) - - // 👇 To make LoginTypeSelector available to all destinations as a non-navigation parameter if provided - if (loginTypeSelector != null) dependency(loginTypeSelector) - - // 👇 To tie NewConversationViewModel to nested NewConversationGraph, - // making it shared between all screens that belong to it - navGraph(NewConversationGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewConversationGraph.route) - } - dependency( - newConversationViewModel(parentEntry) - ) - } - - // 👇 To reuse LoginEmailViewModel from NewLoginPasswordScreen on NewLoginVerificationCodeScreen - destination(NewLoginVerificationCodeScreenDestination) { - val loginPasswordEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewLoginPasswordScreenDestination.route) - } - dependency( - loginEmailViewModel( - loginNavArgs = loginPasswordEntry.navArgs(), - viewModelStoreOwner = loginPasswordEntry, - ) - ) - } - - destination(SearchScreenDestination) { - val cellViewModelOwner = remember(navBackStackEntry) { - val previousEntry = navController.previousBackStackEntry - when (previousEntry?.safeRoute()?.baseRoute) { - ConversationFilesScreenDestination.baseRoute, - ConversationFilesWithSlideInTransitionScreenDestination.baseRoute -> previousEntry - - else -> previousEntry ?: navBackStackEntry - } - } - dependency(cellViewModel(cellViewModelOwner)) - } - - // 👇 To tie NewConversationViewModel to NewConversationGraph, - // making it shared between all screens that belong to it - navGraph(NewConversationGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewConversationGraph.route) - } - dependency(newConversationViewModel(parentEntry)) - } - - // 👇 To tie TeamMigrationViewModel to PersonalToTeamMigrationNavGraph, - // making it shared between all screens that belong to it - navGraph(PersonalToTeamMigrationGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(PersonalToTeamMigrationGraph.route) - } - dependency(teamMigrationViewModel(parentEntry)) - } - - // 👇 To tie NewMeetingViewModel to nested NewMeetingGraph, making it shared between all screens that belong to it - // Also, make MeetingNavigator available to all destinations from NewMeetingGraph as a non-navigation parameter - navGraph(NewMeetingGraph) { - val parentEntry = remember(navBackStackEntry) { - navController.getBackStackEntry(NewMeetingGraph.route) - } - dependency(newMeetingViewModel(parentEntry)) - dependency(meetingNavigator) - } - }, - manualComposableCallsBuilder = { - /** - * Keep manual composable calls for cross-module result wiring until we refactor - * those destinations to rely on generated dependencies directly. - */ - composable(ConversationScreenDestination) { - ConversationScreen( - navigator = navigator, - groupDetailsScreenResultRecipient = resultRecipient(groupConversationDetailsNavBackArgsNavType), - mediaGalleryScreenResultRecipient = resultRecipient(mediaGalleryNavBackArgsNavType), - imagePreviewScreenResultRecipient = resultRecipient(imagesPreviewNavBackArgsNavType), - drawingCanvasScreenResultRecipient = resultRecipient( - drawingCanvasNavBackArgsNavType - ), - resultNavigator = resultBackNavigator(groupConversationDetailsNavBackArgsNavType), - ) - } - } - ) - } - } -} diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt deleted file mode 100644 index ace9f28afce..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation - -import android.annotation.SuppressLint -import android.content.Context -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import com.ramcosta.composedestinations.generated.app.navgraphs.WireRootGraph -import com.ramcosta.composedestinations.navigation.DestinationsNavOptionsBuilder -import com.ramcosta.composedestinations.spec.DestinationSpec -import com.ramcosta.composedestinations.spec.Direction -import com.ramcosta.composedestinations.spec.NavGraphSpec -import com.ramcosta.composedestinations.spec.Route -import com.ramcosta.composedestinations.utils.destination -import com.ramcosta.composedestinations.utils.findDestination -import com.ramcosta.composedestinations.utils.navGraph -import com.ramcosta.composedestinations.utils.route -import com.ramcosta.composedestinations.utils.toDestinationsNavigator -import com.wire.android.appLogger -import com.wire.android.util.CustomTabsHelper - -@SuppressLint("RestrictedApi") -@Suppress("CyclomaticComplexMethod") -internal fun NavController.navigateToItem(command: NavigationCommand) { - - fun firstDestination() = currentBackStack.value.firstOrNull { it.safeRoute() is DestinationSpec } - fun lastDestination() = currentBackStack.value.lastOrNull { it.safeRoute() is DestinationSpec } - fun lastNestedGraph() = lastDestination()?.takeIf { it.navGraph() != navGraph }?.navGraph() - fun firstDestinationWithRoute(route: String) = - currentBackStack.value.firstOrNull { it.destination.route?.getBaseRoute() == route.getBaseRoute() } - - fun lastDestinationFromOtherGraph(graph: NavGraphSpec) = currentBackStack.value.lastOrNull { it.navGraph() != graph } - - appLogger.d("[$TAG] -> command: ${command.destination.baseRoute} backStackMode:${command.backStackMode}") - toDestinationsNavigator().navigate(command.destination) { - when (command.backStackMode) { - BackStackMode.CLEAR_WHOLE, BackStackMode.CLEAR_TILL_START -> { - val inclusive = command.backStackMode == BackStackMode.CLEAR_WHOLE - popUpTo(inclusive) { firstDestination() } - } - - BackStackMode.REMOVE_CURRENT -> { - popUpTo(true) { lastDestination() } - } - - BackStackMode.REMOVE_CURRENT_NESTED_GRAPH -> { - popUpTo( - getInclusive = { it.safeRoute() is NavGraphSpec }, - getNavBackStackEntry = { lastNestedGraph()?.let { lastDestinationFromOtherGraph(it) } } - ) - } - - BackStackMode.REMOVE_CURRENT_AND_REPLACE -> { - popUpTo(true) { lastDestination() } - popUpTo(true) { firstDestinationWithRoute(command.destination.route) } - } - - BackStackMode.UPDATE_EXISTED -> { - popUpTo(true) { firstDestinationWithRoute(command.destination.route) } - } - - BackStackMode.POP_CONSECUTIVE_SAME_SCREENS -> { - val currentRoute = currentBackStackEntry?.destination?.route - while (previousBackStackEntry?.destination?.route == currentRoute) { - popBackStack() - } - popBackStack() - } - - BackStackMode.NONE -> {} - } - launchSingleTop = command.launchSingleTop - restoreState = true - } -} - -private fun DestinationsNavOptionsBuilder.popUpTo( - inclusive: Boolean, - getNavBackStackEntry: () -> NavBackStackEntry?, -) = popUpTo({ inclusive }, getNavBackStackEntry) - -private fun DestinationsNavOptionsBuilder.popUpTo( - getInclusive: (NavBackStackEntry) -> Boolean, - getNavBackStackEntry: () -> NavBackStackEntry?, -) { - getNavBackStackEntry()?.let { entry -> - appLogger.d("[$TAG] -> popUpTo:${entry.destination.route?.getBaseRoute()} inclusive:${getInclusive(entry)}") - entry.safeRoute()?.let { route -> - popUpTo(route) { - this.inclusive = getInclusive(entry) - } - } - } -} - -internal fun NavDestination.toDestination(): DestinationSpec? = - this.route?.let { currentRoute -> WireRootGraph.findDestination(currentRoute) } - -fun String.getBaseRoute(): String { - var slashCount = 0 - val end = indexOfFirst { c -> (c == '/' && ++slashCount == 2) || c == '?' } - return if (end >= 0) substring(0, end) else this -} - -/** - * Returns the base route of a [Direction] without argument values. - * Uses the pre-computed [Route.baseRoute] when available (zero-cost), - * falling back to [String.getBaseRoute] for plain [Direction] instances. - */ -val Direction.baseRoute: String - get() = (this as? Route)?.baseRoute ?: route.getBaseRoute() - -fun Direction.handleNavigation( - context: Context, - handleOtherDirection: (Direction) -> Unit -) = when (this) { - is ExternalUriDirection -> CustomTabsHelper.launchUri(context, this.uri) - is ExternalUriStringResDirection -> CustomTabsHelper.launchUrl(context, this.getUriString(context.resources)) - is IntentDirection -> context.startActivity(this.intent(context)) - else -> handleOtherDirection(this) -} - -@SuppressLint("RestrictedApi") -fun NavController.startDestination() = currentBackStack.value.firstOrNull { it.safeRoute() is DestinationSpec } - -internal fun NavBackStackEntry.safeRoute(): Route? = runCatching { route() }.getOrNull() - -internal fun NavBackStackEntry.safeDestination(): DestinationSpec? = runCatching { destination() }.getOrNull() - -private const val TAG = "NavigationUtils" diff --git a/app/src/main/kotlin/com/wire/android/navigation/Navigator.kt b/app/src/main/kotlin/com/wire/android/navigation/Navigator.kt deleted file mode 100644 index dade697530d..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/Navigator.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.navigation.NavHostController -import com.ramcosta.composedestinations.generated.app.navgraphs.WireRootGraph -import com.ramcosta.composedestinations.utils.findDestination - -@Stable -class Navigator( - val finish: () -> Unit, - val navController: NavHostController, - val isAllowedToNavigate: (NavigationCommand) -> Boolean = { true } -) : WireNavigator { - /** - * Navigates to the specified screen if it is allowed to navigate. - * @param navigationCommand command containing the destination and back stack mode - * is not in the RESUMED state. This avoids duplicate navigation actions and should be used when it's the user action - * or when we simply don't want to make more than one navigation action at a time (skip some destinations instantly). - * More here: https://composedestinations.rafaelcosta.xyz/navigation/basics#avoiding-duplicate-navigation - */ - override fun navigate(navigationCommand: NavigationCommand) { - if (!isAllowedToNavigate(navigationCommand)) return - navController.navigateToItem(navigationCommand) - } - - /** - * Navigates back to the previous screen. - * is not in the RESUMED state. This avoids duplicate navigation actions and should be used when it's the user action - * or when we simply don't want to make more than one navigation action at a time (skip some destinations instantly). - * More here: https://composedestinations.rafaelcosta.xyz/navigation/basics#avoiding-duplicate-navigation - */ - override fun navigateBack() { - if (!navController.popBackStack()) finish() - } - - override fun navigateBackAndRemoveAllConsecutive(currentRoute: String) { - while (navController.currentBackStackEntry?.destination?.route == currentRoute) { - navController.popBackStack() - } - } - - override fun navigateBackAndRemoveAllConsecutiveXTimes(currentRoute: String, stepsBack: Int) { - var steps = stepsBack - while (steps > 0 && navController.currentBackStackEntry?.destination?.route == currentRoute) { - navController.popBackStack() - steps-- - } - } -} - -@Composable -fun rememberNavigator( - isAllowedToNavigate: (NavigationCommand) -> Boolean = { true }, - finish: () -> Unit, -): Navigator { - val navController = rememberTrackingAnimatedNavController { - WireRootGraph.findDestination(it)?.let { it::class.simpleName } // there is a proguard rule for Routes - } - return remember(finish, isAllowedToNavigate, navController) { Navigator(finish, navController, isAllowedToNavigate) } -} diff --git a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt deleted file mode 100644 index c6eb97133f0..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/OtherDestinations.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import android.content.Context -import android.content.Intent -import android.content.res.Resources -import android.net.Uri -import androidx.annotation.StringRes -import com.ramcosta.composedestinations.spec.Direction -import com.wire.android.BuildConfig -import com.wire.android.R -import com.wire.android.util.EmailComposer -import com.wire.android.util.SupportPage -import com.wire.android.util.SupportUrlResolver -import com.wire.android.util.externalShareChooserIntent -import com.wire.android.util.getDeviceIdString -import com.wire.android.util.getGitBuildId -import com.wire.android.util.sha256 - -/** - * The route is not that relevant as won't be used for navigation, it will be overridden by a direct custom tab launch. - */ -interface ExternalDirectionLess : Direction { - override val route: String - get() = this::class.qualifiedName.orEmpty() -} - -interface ExternalUriDirection : Direction { - val uri: Uri - override val route: String - get() = uri.toString() -} - -interface ExternalUriStringResDirection : Direction { - @get:StringRes - val uriStringRes: Int - override val route: String - get() = "android.resource://${BuildConfig.APPLICATION_ID}/$uriStringRes" - - fun getUriString(resources: Resources): String = resources.getString(uriStringRes) - - fun getUri(resources: Resources): Uri = Uri.parse(getUriString(resources)) -} - -interface ExternalSupportUriStringResDirection : ExternalUriStringResDirection { - val supportPage: SupportPage - - override val uriStringRes: Int - get() = supportPage.hardcodedUrlRes - - override fun getUriString(resources: Resources): String = - SupportUrlResolver.resolve(resources, supportPage) -} - -interface IntentDirection : Direction { - fun intent(context: Context): Intent -} - -object SupportScreenDestination : ExternalSupportUriStringResDirection { - override val supportPage: SupportPage - get() = SupportPage.SUPPORT -} - -object ReportMisuseScreenDestination : ExternalSupportUriStringResDirection { - override val supportPage: SupportPage - get() = SupportPage.REPORT_MISUSE -} - -data object TeamManagementScreenDestination : ExternalDirectionLess - -object PrivacyPolicyScreenDestination : ExternalUriStringResDirection { - override val uriStringRes: Int - get() = R.string.url_privacy_policy -} - -object TermsOfUseScreenDestination : ExternalUriStringResDirection { - override val uriStringRes: Int - get() = R.string.url_terms_of_use_legal -} - -object WireWebsiteScreenDestination : ExternalUriStringResDirection { - override val uriStringRes: Int - get() = R.string.url_wire_website -} - -object GiveFeedbackDestination : IntentDirection { - override fun intent(context: Context): Intent { - val intent = Intent(Intent.ACTION_SEND) - intent.putExtra( - Intent.EXTRA_EMAIL, - arrayOf("wire-newandroid-feedback@wearezeta.zendesk.com") - ) - intent.putExtra(Intent.EXTRA_SUBJECT, "Feedback - Wire Beta") - intent.putExtra( - Intent.EXTRA_TEXT, - EmailComposer.giveFeedbackEmailTemplate( - context.getDeviceIdString()?.sha256(), - context.getGitBuildId() - ) - ) - intent.selector = Intent(Intent.ACTION_SENDTO).setData(Uri.parse("mailto:")) - return context.externalShareChooserIntent(intent, context.getString(R.string.send_feedback_choose_email)) - } - - override val route: String - get() = "wire-intent:give-feedback" -} - -object WelcomeToNewAndroidAppDestination : ExternalSupportUriStringResDirection { - override val supportPage: SupportPage - get() = SupportPage.WELCOME_ANDROID -} - -object AndroidReleaseNotesDestination : ExternalUriStringResDirection { - override val uriStringRes: Int - get() = R.string.url_android_release_notes -} diff --git a/app/src/main/kotlin/com/wire/android/navigation/TabletDialogRoutePolicy.kt b/app/src/main/kotlin/com/wire/android/navigation/TabletDialogRoutePolicy.kt deleted file mode 100644 index 60a34872a76..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/TabletDialogRoutePolicy.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation - -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.wire.android.navigation.style.DialogNavigation - -/** - * Single source of truth for destinations that must preserve pre-migration tablet dialog behavior. - * - * See ADR-0010 for rationale and maintenance rules: - * docs/adr/0010-tablet-dialog-navigation-parity-after-compose-destinations-upgrade.md - */ -internal object TabletDialogRoutePolicy { - internal val destinationBaseRoutes: Set = setOf( - "app/service_details_screen", - "app/other_user_profile_screen", - "app/self_user_profile_screen", - "app/device_details_screen", - "app/change_display_name_screen", - "app/change_handle_screen", - "app/change_email_screen", - "app/avatar_picker_screen", - "app/group_conversation_details_screen", - "app/edit_conversation_name_screen", - "app/edit_guest_access_screen", - "app/update_apps_access_screen", - "app/channel_access_on_update_screen", - "app/edit_self_deleting_messages_screen", - "app/conversation_folders_screen", - "app/new_conversation_folder_screen", - "app/change_user_color_screen", - ) - - internal fun shouldShowAsDialog(baseRoute: String): Boolean = - baseRoute in destinationBaseRoutes -} - -/** - * Resolves the runtime style for a destination while preserving manual animation overrides. - */ -internal fun resolveTabletDialogParityStyle( - destinationRoute: String, - originalStyle: DestinationStyle, - manualAnimation: DestinationStyle.Animated?, - isTablet: Boolean, -): DestinationStyle { - val destinationBaseRoute = destinationRoute.getBaseRoute() - return if (isTablet && TabletDialogRoutePolicy.shouldShowAsDialog(destinationBaseRoute)) { - DialogNavigation - } else { - manualAnimation ?: originalStyle - } -} diff --git a/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt b/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt deleted file mode 100644 index 3c9f9ad205e..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/TabletStyleHelper.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import androidx.compose.runtime.Composable - -// Todo(docs): Add ADR about this change introduced in navigation styles for tablets, which requires -// adjusting navigation styles for certain destinations when on tablets. -// See ADR-0010: -// docs/adr/0010-tablet-dialog-navigation-parity-after-compose-destinations-upgrade.md -@Composable -fun AdjustDestinationStylesForTablets() = Unit diff --git a/app/src/main/kotlin/com/wire/android/navigation/WireNavHostEngine.kt b/app/src/main/kotlin/com/wire/android/navigation/WireNavHostEngine.kt deleted file mode 100644 index cd362584012..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/WireNavHostEngine.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.navigation.NavDestination -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.Navigator -import androidx.navigation.compose.navigation -import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle -import com.ramcosta.composedestinations.manualcomposablecalls.ManualComposableCalls -import com.ramcosta.composedestinations.manualcomposablecalls.allDeepLinks -import com.ramcosta.composedestinations.navigation.DependenciesContainerBuilder -import com.ramcosta.composedestinations.spec.Direction -import com.ramcosta.composedestinations.spec.NavGraphSpec -import com.ramcosta.composedestinations.spec.NavHostEngine -import com.ramcosta.composedestinations.spec.TypedDestinationSpec -import com.wire.android.navigation.wrapper.setTabletDialogRouteMatcher - -@Composable -internal fun rememberWireNavHostEngine( - navHostContentAlignment: Alignment = Alignment.Center, -): NavHostEngine = remember(navHostContentAlignment) { - // Keep wrapper clipping aligned with the same tablet-dialog route policy used by style resolution. - setTabletDialogRouteMatcher { route -> - TabletDialogRoutePolicy.shouldShowAsDialog(route.getBaseRoute()) - } - WireNavHostEngine( - navHostContentAlignment = navHostContentAlignment, - ) -} - -/** - * a custom NavHostEngine implementation for Wire that: - * 1. Sets a default content alignment for NavHost composable. - * 2. Implements custom logic for determining when to apply animated transitions to navigation graphs and destinations - * 3. Implements custom logic for determining when to apply dialog-style presentation to destinations, based on a tablet - * parity policy. - * This is done to ensure that all routes that should be presented as dialogs on tablets are registered - * as such in the NavGraph, even if they are not registered as dialogs on phones. - * - * Note: MUST BE KEPT IN SYNC WITH UPSTREAM WHEN UPDATING COMPOSE DESTINATIONS DEPENDENCY - */ - -// Required because compose-destinations 2.3.0 exposes ManualComposableCalls -// (@RestrictTo(LIBRARY_GROUP)) in the public NavHostEngine interface; implementing -// that interface outside the library otherwise triggers RestrictedApi lint. -@SuppressLint("RestrictedApi") -internal class WireNavHostEngine( - private val navHostContentAlignment: Alignment, -) : NavHostEngine { - - override val type = NavHostEngine.Type.DEFAULT - - @Composable - override fun rememberNavController( - vararg navigators: Navigator - ) = androidx.navigation.compose.rememberNavController(*navigators) - - @Composable - override fun NavHost( - modifier: Modifier, - route: String, - start: Direction, - defaultTransitions: NavHostAnimatedDestinationStyle, - navController: NavHostController, - builder: NavGraphBuilder.() -> Unit, - ) = with(defaultTransitions) { - androidx.navigation.compose.NavHost( - navController = navController, - startDestination = start.route, - modifier = modifier, - route = route, - contentAlignment = navHostContentAlignment, - enterTransition = enterTransition, - exitTransition = exitTransition, - popEnterTransition = popEnterTransition, - popExitTransition = popExitTransition, - sizeTransform = sizeTransform, - builder = builder - ) - } - - override fun NavGraphBuilder.navigation( - navGraph: NavGraphSpec, - manualComposableCalls: ManualComposableCalls, - builder: NavGraphBuilder.() -> Unit - ) { - val transitions = manualComposableCalls.manualAnimation(navGraph.route) - ?: navGraph.defaultTransitions - - if (transitions != null) { - with(transitions) { - navigation( - startDestination = navGraph.startRoute.route, - route = navGraph.route, - arguments = navGraph.arguments, - deepLinks = navGraph.allDeepLinks(manualComposableCalls), - enterTransition = enterTransition, - exitTransition = exitTransition, - popEnterTransition = popEnterTransition, - popExitTransition = popExitTransition, - sizeTransform = sizeTransform, - builder = builder, - ) - } - } else { - navigation( - startDestination = navGraph.startRoute.route, - route = navGraph.route, - arguments = navGraph.arguments, - deepLinks = navGraph.allDeepLinks(manualComposableCalls), - builder = builder - ) - } - } - - override fun NavGraphBuilder.composable( - destination: TypedDestinationSpec, - navController: NavHostController, - dependenciesContainerBuilder: @Composable DependenciesContainerBuilder<*>.() -> Unit, - manualComposableCalls: ManualComposableCalls, - ) { - // Force true dialog registration for tablet parity routes; keep default/manual behavior otherwise. - val resolvedStyle = resolveTabletDialogParityStyle( - destinationRoute = destination.route, - originalStyle = destination.style, - manualAnimation = manualComposableCalls.manualAnimation(destination.route), - isTablet = navController.context.resources.configuration.smallestScreenWidthDp >= TABLET_MIN_SCREEN_WIDTH_DP, - ) - with(resolvedStyle) { - addComposable(destination, navController, dependenciesContainerBuilder, manualComposableCalls) - } - } -} - -private const val TABLET_MIN_SCREEN_WIDTH_DP = 600 diff --git a/app/src/main/kotlin/com/wire/android/navigation/WireRootNavGraph.kt b/app/src/main/kotlin/com/wire/android/navigation/WireRootNavGraph.kt deleted file mode 100644 index 3f922382fd9..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/WireRootNavGraph.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import com.ramcosta.composedestinations.annotation.ExternalNavGraph -import com.ramcosta.composedestinations.annotation.NavHostGraph -import com.ramcosta.composedestinations.generated.cells.navgraphs.CellsGraph -import com.ramcosta.composedestinations.generated.meetings.navgraphs.NewMeetingGraph -import com.ramcosta.composedestinations.generated.sketch.navgraphs.SketchGraph -import com.wire.android.navigation.style.DefaultRootNavGraphAnimations - -@NavHostGraph(defaultTransitions = DefaultRootNavGraphAnimations::class) -annotation class WireRootNavGraph { - @ExternalNavGraph - @ExternalNavGraph - @ExternalNavGraph - companion object Includes -} diff --git a/app/src/main/kotlin/com/wire/android/navigation/annotation/app/AppDestinations.kt b/app/src/main/kotlin/com/wire/android/navigation/annotation/app/AppDestinations.kt deleted file mode 100644 index fc43d67d7a7..00000000000 --- a/app/src/main/kotlin/com/wire/android/navigation/annotation/app/AppDestinations.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.annotation.app - -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.Destination.Companion.COMPOSABLE_NAME -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.wire.android.navigation.WireRootNavGraph -import com.wire.android.navigation.wrapper.TabletDialogWrapper -import com.wire.android.navigation.wrapper.WaitUntilTransitionEndsWrapper -import com.wire.android.ui.authentication.create.common.CreateAccountNavGraph -import com.wire.android.ui.authentication.create.common.CreatePersonalAccountNavGraph -import com.wire.android.ui.authentication.create.common.CreateTeamAccountNavGraph -import com.wire.android.ui.authentication.login.LoginNavGraph -import com.wire.android.ui.authentication.login.NewLoginNavGraph -import com.wire.android.ui.home.HomeNavGraph -import com.wire.android.ui.home.newconversation.common.NewConversationNavGraph -import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph -import kotlin.reflect.KClass - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireRootDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireHomeDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireLoginDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireNewLoginDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireCreateAccountDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireCreatePersonalAccountDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireCreateTeamAccountDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireNewConversationDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WirePersonalToTeamMigrationDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt new file mode 100644 index 00000000000..38cb213f751 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt @@ -0,0 +1,227 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +@file:Suppress("TooManyFunctions") + +package com.wire.android.navigation.routes.auth + +import com.wire.android.ui.authentication.login.DomainClaimedByOrg +import com.wire.android.ui.authentication.login.LoginNavArgs +import com.wire.android.ui.authentication.login.LoginPasswordPath +import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType +import com.wire.android.ui.authentication.login.SSOCodeAutoLogin +import com.wire.android.ui.authentication.welcome.WelcomeNavArgs +import com.wire.android.util.deeplink.DeepLinkResult +import com.wire.android.util.deeplink.SSOFailureCodes +import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.navigation.WireNavEntryId + +internal fun LoginNavArgs.toAuthenticationArguments(): AuthenticationLoginArguments = + AuthenticationLoginArguments( + userHandle = userHandle?.let { + AuthenticationPrefilledUserIdentifier( + value = it.userIdentifier, + editable = it.editable, + ) + }, + ssoLoginResult = ssoLoginResult?.toAuthenticationSsoLoginResult(), + loginPasswordPath = loginPasswordPath?.toAuthenticationLoginPasswordPath(), + ssoCodeAutoLogin = ssoCodeAutoLogin?.let { + AuthenticationSsoCodeAutoLogin( + ssoCode = it.ssoCode, + autoInitiateLogin = it.autoInitiateLogin, + nomadServiceUrl = it.nomadServiceUrl, + cookieLabel = it.cookieLabel, + ) + }, + showBackendConfigSuccess = showBackendConfigSuccess, + ) + +internal fun AuthenticationLoginArguments.toLegacy(): LoginNavArgs = + LoginNavArgs( + userHandle = userHandle?.let { + PreFilledUserIdentifierType.PreFilled( + userIdentifier = it.value, + editable = it.editable, + ) + }, + ssoLoginResult = ssoLoginResult?.toLegacy(), + loginPasswordPath = loginPasswordPath?.toLegacy(), + ssoCodeAutoLogin = ssoCodeAutoLogin?.let { + SSOCodeAutoLogin( + ssoCode = it.ssoCode, + autoInitiateLogin = it.autoInitiateLogin, + nomadServiceUrl = it.nomadServiceUrl, + cookieLabel = it.cookieLabel, + ) + }, + showBackendConfigSuccess = showBackendConfigSuccess, + ) + +internal fun LoginNavArgs.toLoginRoute( + entryId: WireNavEntryId = WireNavEntryId.random(), + flowId: String = entryId.value, +): LoginRoute = LoginRoute(toAuthenticationArguments(), entryId, flowId) + +internal fun LoginRoute.toLegacyNavArgs(): LoginNavArgs = args.toLegacy() + +internal fun LoginNavArgs.toNewLoginRoute( + flowId: String, + entryId: WireNavEntryId = WireNavEntryId.random(), +): NewLoginRoute = NewLoginRoute(toAuthenticationArguments(), flowId, entryId) + +internal fun NewLoginRoute.toLegacyNavArgs(): LoginNavArgs = args.toLegacy() + +internal fun LoginNavArgs.toNewLoginPasswordRoute( + flowId: String, + entryId: WireNavEntryId = WireNavEntryId.random(), +): NewLoginPasswordRoute = NewLoginPasswordRoute(toAuthenticationArguments(), flowId, entryId) + +internal fun NewLoginPasswordRoute.toLegacyNavArgs(): LoginNavArgs = args.toLegacy() + +internal fun LoginNavArgs.toNewLoginVerificationCodeRoute( + flowId: String, + entryId: WireNavEntryId = WireNavEntryId.random(), +): NewLoginVerificationCodeRoute = + NewLoginVerificationCodeRoute(toAuthenticationArguments(), flowId, entryId) + +internal fun NewLoginVerificationCodeRoute.toLegacyNavArgs(): LoginNavArgs = args.toLegacy() + +internal fun WelcomeNavArgs.toWelcomeRoute( + entryId: WireNavEntryId = WireNavEntryId.random(), + flowId: String = entryId.value, +): WelcomeRoute = WelcomeRoute(customServerConfig?.toAuthenticationServerLinks(), entryId, flowId) + +internal fun WelcomeRoute.toLegacyNavArgs(): WelcomeNavArgs = + WelcomeNavArgs(customServerConfig?.toLegacy()) + +internal fun DeepLinkResult.SSOLogin.toAuthenticationSsoLoginResult(): AuthenticationSsoLoginResult = + when (this) { + is DeepLinkResult.SSOLogin.Success -> AuthenticationSsoLoginResult.Success( + cookie = cookie, + serverConfigId = serverConfigId, + ) + is DeepLinkResult.SSOLogin.Failure -> AuthenticationSsoLoginResult.Failure( + code = ssoError.toAuthenticationSsoFailureCode(), + ) + } + +private fun AuthenticationSsoLoginResult.toLegacy(): DeepLinkResult.SSOLogin = + when (this) { + is AuthenticationSsoLoginResult.Success -> DeepLinkResult.SSOLogin.Success( + cookie = cookie, + serverConfigId = serverConfigId, + ) + is AuthenticationSsoLoginResult.Failure -> DeepLinkResult.SSOLogin.Failure(code.toLegacy()) + } + +private fun SSOFailureCodes.toAuthenticationSsoFailureCode(): AuthenticationSsoFailureCode = + when (this) { + SSOFailureCodes.ServerErrorUnsupportedSaml -> AuthenticationSsoFailureCode.SERVER_ERROR_UNSUPPORTED_SAML + SSOFailureCodes.BadSuccessRedirect -> AuthenticationSsoFailureCode.BAD_SUCCESS_REDIRECT + SSOFailureCodes.BadFailureRedirect -> AuthenticationSsoFailureCode.BAD_FAILURE_REDIRECT + SSOFailureCodes.BadUsername -> AuthenticationSsoFailureCode.BAD_USERNAME + SSOFailureCodes.BadUpstream -> AuthenticationSsoFailureCode.BAD_UPSTREAM + SSOFailureCodes.ServerError -> AuthenticationSsoFailureCode.SERVER_ERROR + SSOFailureCodes.NotFound -> AuthenticationSsoFailureCode.NOT_FOUND + SSOFailureCodes.Forbidden -> AuthenticationSsoFailureCode.FORBIDDEN + SSOFailureCodes.NoMatchingAuthReq -> AuthenticationSsoFailureCode.NO_MATCHING_AUTH_REQUEST + SSOFailureCodes.InsufficientPermissions -> AuthenticationSsoFailureCode.INSUFFICIENT_PERMISSIONS + SSOFailureCodes.Unknown -> AuthenticationSsoFailureCode.UNKNOWN + } + +private fun AuthenticationSsoFailureCode.toLegacy(): SSOFailureCodes = + when (this) { + AuthenticationSsoFailureCode.SERVER_ERROR_UNSUPPORTED_SAML -> SSOFailureCodes.ServerErrorUnsupportedSaml + AuthenticationSsoFailureCode.BAD_SUCCESS_REDIRECT -> SSOFailureCodes.BadSuccessRedirect + AuthenticationSsoFailureCode.BAD_FAILURE_REDIRECT -> SSOFailureCodes.BadFailureRedirect + AuthenticationSsoFailureCode.BAD_USERNAME -> SSOFailureCodes.BadUsername + AuthenticationSsoFailureCode.BAD_UPSTREAM -> SSOFailureCodes.BadUpstream + AuthenticationSsoFailureCode.SERVER_ERROR -> SSOFailureCodes.ServerError + AuthenticationSsoFailureCode.NOT_FOUND -> SSOFailureCodes.NotFound + AuthenticationSsoFailureCode.FORBIDDEN -> SSOFailureCodes.Forbidden + AuthenticationSsoFailureCode.NO_MATCHING_AUTH_REQUEST -> SSOFailureCodes.NoMatchingAuthReq + AuthenticationSsoFailureCode.INSUFFICIENT_PERMISSIONS -> SSOFailureCodes.InsufficientPermissions + AuthenticationSsoFailureCode.UNKNOWN -> SSOFailureCodes.Unknown + } + +private fun LoginPasswordPath.toAuthenticationLoginPasswordPath(): AuthenticationLoginPasswordPath = + AuthenticationLoginPasswordPath( + customServerConfig = customServerConfig?.toAuthenticationServerLinks(), + isCloudAccountCreationPossible = isCloudAccountCreationPossible, + domainClaim = isDomainClaimedByOrg.toAuthenticationDomainClaim(), + ) + +private fun AuthenticationLoginPasswordPath.toLegacy(): LoginPasswordPath = + LoginPasswordPath( + customServerConfig = customServerConfig?.toLegacy(), + isCloudAccountCreationPossible = isCloudAccountCreationPossible, + isDomainClaimedByOrg = domainClaim.toLegacy(), + ) + +private fun DomainClaimedByOrg.toAuthenticationDomainClaim(): AuthenticationDomainClaim = + when (this) { + DomainClaimedByOrg.NotClaimed -> AuthenticationDomainClaim.NotClaimed + is DomainClaimedByOrg.Claimed -> AuthenticationDomainClaim.Claimed(domain) + } + +private fun AuthenticationDomainClaim.toLegacy(): DomainClaimedByOrg = + when (this) { + AuthenticationDomainClaim.NotClaimed -> DomainClaimedByOrg.NotClaimed + is AuthenticationDomainClaim.Claimed -> DomainClaimedByOrg.Claimed(domain) + } + +internal fun ServerConfig.Links.toAuthenticationServerLinks(): AuthenticationServerLinks = + AuthenticationServerLinks( + api = api, + accounts = accounts, + webSocket = webSocket, + blackList = blackList, + teams = teams, + website = website, + title = title, + isOnPremises = isOnPremises, + apiProxy = apiProxy?.let { + AuthenticationApiProxy( + needsAuthentication = it.needsAuthentication, + host = it.host, + port = it.port, + ) + }, + supportEmail = supportEmail, + ) + +internal fun AuthenticationServerLinks.toLegacy(): ServerConfig.Links = + ServerConfig.Links( + api = api, + accounts = accounts, + webSocket = webSocket, + blackList = blackList, + teams = teams, + website = website, + title = title, + isOnPremises = isOnPremises, + apiProxy = apiProxy?.let { + ServerConfig.ApiProxy( + needsAuthentication = it.needsAuthentication, + host = it.host, + port = it.port, + ) + }, + supportEmail = supportEmail, + ) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt new file mode 100644 index 00000000000..66063dc0d69 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt @@ -0,0 +1,417 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.ui.authentication.login.LoginNavArgs +import com.wire.android.ui.authentication.login.LoginPasswordPath +import com.wire.android.ui.authentication.login.LoginRouteScreen +import com.wire.android.ui.authentication.login.email.LoginEmailViewModel +import com.wire.android.ui.authentication.loginEmailViewModel +import com.wire.android.ui.authentication.newLoginViewModel +import com.wire.android.ui.authentication.welcome.WelcomeRouteScreen +import com.wire.android.ui.authentication.welcome.WelcomeScreenAction +import com.wire.android.ui.authentication.welcomeViewModel +import com.wire.android.ui.newauthentication.login.NewLoginAction +import com.wire.android.ui.newauthentication.login.NewLoginRouteScreen +import com.wire.android.ui.newauthentication.login.code.NewLoginVerificationCodeRouteScreen +import com.wire.android.ui.newauthentication.login.password.LoginStateNavigationAndDialogs +import com.wire.android.ui.newauthentication.login.password.NewLoginPasswordRouteScreen +import com.wire.android.ui.newauthentication.login.password.NewLoginPasswordScreenAction +import com.wire.android.ui.newauthentication.welcome.NewWelcomeEmptyStartScreen +import com.wire.android.ui.newauthentication.welcome.WelcomeChooserScreen +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner + +/** + * Cross-batch operations emitted by the first authentication entries. + * + * The host owns routes that have not been migrated in this batch. Keeping those boundaries + * semantic prevents the entries from importing generated destinations or a Navigation 2 + * controller. + */ +internal interface AuthenticationNavigation3Actions { + fun canUseNewLogin(): Boolean + fun exitAuthentication() + fun openUrl(url: String) + fun openTeamAccountCreation(request: AuthenticationTeamAccountCreationRequest) +} + +internal sealed interface AuthenticationLoginCompletion { + data class Home(val sessionId: WireSessionId) : AuthenticationLoginCompletion + data class InitialSync(val sessionId: WireSessionId) : AuthenticationLoginCompletion + data class E2EIEnrollment(val sessionId: WireSessionId) : AuthenticationLoginCompletion + data class RemoveDevice(val sessionId: WireSessionId) : AuthenticationLoginCompletion +} + +internal data class AuthenticationTeamAccountCreationRequest( + val url: String, + val returnRoute: NewLoginPasswordRoute, +) + +internal object AuthenticationNavigation3Contribution { + val resultTypes = emptyList>() + + fun entryProviderInstallers( + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, + ): List = listOf( + authenticationNavigation3Entries(actions, router), + createAccountNavigation3Entries(actions, router), + ) +} + +internal fun authenticationNavigation3Entries( + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.None) { route -> + WelcomeChooserScreen { + val destination = if (actions.canUseNewLogin()) { + NewLoginRoute( + flowId = route.newLoginFlowId(), + ) + } else { + WelcomeRoute(flowId = route.newLoginFlowId()) + } + router.navigate(AuthenticationNavigationTransition.CHOOSER_TO_LOGIN, destination) + } + } + wireEntry(presentation = WireEntryPresentation.None) { + NewWelcomeEmptyStartScreen() + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + WelcomeNavigation3Entry(route, actions, router) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + LoginNavigation3Entry(route, actions, router) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + NewLoginNavigation3Entry(route, actions, router) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + NewLoginPasswordNavigation3Entry(route, actions, router) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + NewLoginVerificationCodeNavigation3Entry(route, actions, router) + } +} + +@Composable +private fun WelcomeNavigation3Entry( + route: WelcomeRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + WelcomeRouteScreen( + viewModel = welcomeViewModel(route.toLegacyNavArgs(), flowOwner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onAction = { action -> + when (action) { + is WelcomeScreenAction.Login -> router.navigate( + AuthenticationNavigationTransition.WELCOME_TO_LOGIN, + LoginNavArgs( + loginPasswordPath = LoginPasswordPath(action.serverConfig) + ).toLoginRoute(flowId = route.flowId) + ) + + is WelcomeScreenAction.OpenUrl -> actions.openUrl(action.url) + is WelcomeScreenAction.CreateTeam -> router.navigate( + AuthenticationNavigationTransition.WELCOME_TO_TEAM_ACCOUNT, + CreateTeamAccountOverviewRoute( + customServerConfig = action.serverConfig.toAuthenticationServerLinks(), + flowId = route.createAccountFlowId(), + ) + ) + + is WelcomeScreenAction.CreatePersonal -> router.navigate( + AuthenticationNavigationTransition.WELCOME_TO_PERSONAL_ACCOUNT, + CreatePersonalAccountOverviewRoute( + customServerConfig = action.serverConfig.toAuthenticationServerLinks(), + flowId = route.createAccountFlowId(), + ) + ) + + is WelcomeScreenAction.CreateAccountData -> router.navigate( + AuthenticationNavigationTransition.WELCOME_TO_ACCOUNT_DATA, + CreateAccountDataDetailRoute( + customServerConfig = action.serverConfig.toAuthenticationServerLinks(), + flowId = route.createAccountFlowId(), + ) + ) + } + }, + ) +} + +@Composable +private fun LoginNavigation3Entry( + route: LoginRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val legacyArgs = route.toLegacyNavArgs() + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + LoginRouteScreen( + loginNavArgs = legacyArgs, + loginEmailViewModel = loginEmailViewModel(legacyArgs, flowOwner), + onBackPressed = { router.backOrElse(actions::exitAuthentication) }, + onSuccess = { initialSyncCompleted, isE2EIRequired, userId -> + router.completeLogin( + eventId = route.terminalLoginEventId(), + completion = when { + isE2EIRequired -> AuthenticationLoginCompletion.E2EIEnrollment(userId.toSessionId()) + initialSyncCompleted -> AuthenticationLoginCompletion.Home(userId.toSessionId()) + else -> AuthenticationLoginCompletion.InitialSync(userId.toSessionId()) + }, + ) + }, + onRemoveDeviceNeeded = { + router.completeLogin( + route.terminalLoginEventId(), + AuthenticationLoginCompletion.RemoveDevice(it.toSessionId()), + ) + }, + ) +} + +@Composable +private fun NewLoginNavigation3Entry( + route: NewLoginRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val legacyArgs = route.toLegacyNavArgs() + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + val viewModel = newLoginViewModel(legacyArgs, flowOwner) + + LaunchedEffect(route.args) { + viewModel.onNavigationArgumentsChanged(legacyArgs) + } + + LaunchedEffect(route.entryId.value, route.args.ssoLoginResult) { + legacyArgs.ssoLoginResult?.let(viewModel::handleSSOResult) + } + + NewLoginRouteScreen( + navArgs = legacyArgs, + viewModel = viewModel, + canNavigateBack = router.canNavigateBack, + navigateBack = { router.backOrElse(actions::exitAuthentication) }, + onAction = { action -> + handleNewLoginAction(router, route, viewModel.serverConfig, action, actions) + }, + ) +} + +@Composable +private fun NewLoginPasswordNavigation3Entry( + route: NewLoginPasswordRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val legacyArgs = route.toLegacyNavArgs() + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + val viewModel: LoginEmailViewModel = loginEmailViewModel( + loginNavArgs = legacyArgs, + viewModelStoreOwner = flowOwner, + ) + + NewLoginPasswordRouteScreen( + navArgs = legacyArgs, + loginEmailViewModel = viewModel, + canNavigateBack = router.canNavigateBack, + onAction = { action -> + when (action) { + is NewLoginPasswordScreenAction.Success -> router.completeLogin( + eventId = route.terminalLoginEventId(), + completion = when { + action.isE2EIRequired -> + AuthenticationLoginCompletion.E2EIEnrollment(action.userId.toSessionId()) + action.initialSyncCompleted -> + AuthenticationLoginCompletion.Home(action.userId.toSessionId()) + else -> AuthenticationLoginCompletion.InitialSync(action.userId.toSessionId()) + }, + ) + + is NewLoginPasswordScreenAction.RemoveDevice -> + router.completeLogin( + route.terminalLoginEventId(), + AuthenticationLoginCompletion.RemoveDevice(action.userId.toSessionId()), + ) + + NewLoginPasswordScreenAction.Canceled -> { + router.backOrElse(actions::exitAuthentication) + true + } + is NewLoginPasswordScreenAction.VerificationRequired -> + router.navigate( + AuthenticationNavigationTransition.PASSWORD_TO_VERIFICATION, + action.navArgs.toNewLoginVerificationCodeRoute(route.flowId), + ) + + is NewLoginPasswordScreenAction.CreateAccountSelector -> + router.navigate( + AuthenticationNavigationTransition.PASSWORD_TO_ACCOUNT_SELECTOR, + CreateAccountSelectorRoute( + customServerConfig = action.serverConfig.toAuthenticationServerLinks(), + email = action.email, + flowId = route.createAccountFlowId(), + ) + ) + + is NewLoginPasswordScreenAction.CreatePersonalAccount -> + router.navigate( + AuthenticationNavigationTransition.PASSWORD_TO_PERSONAL_ACCOUNT, + CreatePersonalAccountOverviewRoute( + customServerConfig = action.serverConfig.toAuthenticationServerLinks(), + flowId = route.createAccountFlowId(), + ) + ) + } + }, + ) +} + +@Composable +private fun NewLoginVerificationCodeNavigation3Entry( + route: NewLoginVerificationCodeRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val legacyArgs = route.toLegacyNavArgs() + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + val viewModel: LoginEmailViewModel = loginEmailViewModel( + loginNavArgs = legacyArgs, + viewModelStoreOwner = flowOwner, + ) + + LoginStateNavigationAndDialogs(viewModel) { action -> + when (action) { + is NewLoginPasswordScreenAction.Success -> router.completeLogin( + eventId = route.terminalLoginEventId(), + completion = when { + action.isE2EIRequired -> + AuthenticationLoginCompletion.E2EIEnrollment(action.userId.toSessionId()) + action.initialSyncCompleted -> + AuthenticationLoginCompletion.Home(action.userId.toSessionId()) + else -> AuthenticationLoginCompletion.InitialSync(action.userId.toSessionId()) + }, + ) + + is NewLoginPasswordScreenAction.RemoveDevice -> + router.completeLogin( + route.terminalLoginEventId(), + AuthenticationLoginCompletion.RemoveDevice(action.userId.toSessionId()), + ) + + NewLoginPasswordScreenAction.Canceled -> { + router.backOrElse(actions::exitAuthentication) + true + } + else -> false + } + } + NewLoginVerificationCodeRouteScreen( + loginEmailViewModel = viewModel, + canNavigateBack = router.canNavigateBack, + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + ) +} + +private fun handleNewLoginAction( + router: AuthenticationNavigation3Router, + route: NewLoginRoute, + serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links, + action: NewLoginAction, + actions: AuthenticationNavigation3Actions, +) { + when (action) { + is NewLoginAction.EmailPassword -> router.navigate( + AuthenticationNavigationTransition.LOGIN_TO_PASSWORD, + LoginNavArgs( + userHandle = com.wire.android.ui.authentication.login.PreFilledUserIdentifierType.PreFilled( + action.userIdentifier + ), + loginPasswordPath = action.loginPasswordPath, + ).toNewLoginPasswordRoute(route.flowId), + ) + + is NewLoginAction.CustomConfig -> router.navigate( + AuthenticationNavigationTransition.LOGIN_RESTART_CUSTOM_BACKEND, + LoginNavArgs( + userHandle = com.wire.android.ui.authentication.login.PreFilledUserIdentifierType.PreFilled( + action.userIdentifier + ), + loginPasswordPath = LoginPasswordPath(action.customServerConfig), + ).toNewLoginRoute(route.flowId), + WireBackStackMode.CLEAR_WHOLE, + ) + + is NewLoginAction.SSO -> actions.openUrl(action.url) + is NewLoginAction.Success -> router.completeLogin( + route.terminalLoginEventId(), + action.nextStep.toAuthenticationLoginCompletion(), + ) + is NewLoginAction.EnterpriseLoginNotSupported -> { + router.navigate( + AuthenticationNavigationTransition.LOGIN_FALLBACK_TO_LEGACY, + WelcomeRoute( + customServerConfig = serverConfig.toAuthenticationServerLinks(), + flowId = route.flowId, + ), + WireBackStackMode.CLEAR_WHOLE, + ) + router.navigate( + AuthenticationNavigationTransition.WELCOME_TO_LOGIN, + LoginNavArgs( + userHandle = com.wire.android.ui.authentication.login.PreFilledUserIdentifierType.PreFilled( + userIdentifier = action.userIdentifier, + editable = true, + ), + loginPasswordPath = LoginPasswordPath(serverConfig), + ).toLoginRoute(flowId = route.flowId), + ) + } + } +} + +internal fun NewLoginAction.Success.NextStep.toAuthenticationLoginCompletion(): AuthenticationLoginCompletion = + when (this) { + is NewLoginAction.Success.NextStep.None -> + AuthenticationLoginCompletion.Home(userId.toSessionId()) + is NewLoginAction.Success.NextStep.InitialSync -> + AuthenticationLoginCompletion.InitialSync(userId.toSessionId()) + is NewLoginAction.Success.NextStep.E2EIEnrollment -> + AuthenticationLoginCompletion.E2EIEnrollment(userId.toSessionId()) + is NewLoginAction.Success.NextStep.TooManyDevices -> + AuthenticationLoginCompletion.RemoveDevice(userId.toSessionId()) + } + +private fun UserId.toSessionId(): WireSessionId = + WireSessionId(value = value, domain = domain) + +private fun com.wire.navigation.WireRoute.terminalLoginEventId(): String = + "${entryId.value}:login-terminal" + +internal fun WelcomeChooserRoute.newLoginFlowId(): String = + "new-login:${entryId.value}" + +private fun com.wire.navigation.WireRoute.createAccountFlowId(): String = + "create-account:${entryId.value}" diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt new file mode 100644 index 00000000000..a7c3acf5c03 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt @@ -0,0 +1,314 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.utility.InitialSyncRoute +import com.wire.android.navigation.runtime.WireNavigationDiagnostics +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.home.HomeRequirement +import com.wire.android.ui.authentication.devices.register.RegisterDeviceRoute +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute +import com.wire.android.ui.settings.devices.SessionSetupDestination +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import com.wire.navigation.authenticationSessionFlowId + +/** + * The executable topology of authentication navigation. + * + * Entry providers translate UI callbacks into one of these transitions. Only this router mutates + * the Navigation 3 stack for authentication screens, making every edge observable and testable. + */ +internal enum class AuthenticationNavigationTransition { + CHOOSER_TO_LOGIN, + WELCOME_TO_LOGIN, + WELCOME_TO_PERSONAL_ACCOUNT, + WELCOME_TO_TEAM_ACCOUNT, + WELCOME_TO_ACCOUNT_DATA, + LOGIN_TO_PASSWORD, + LOGIN_RESTART_CUSTOM_BACKEND, + LOGIN_FALLBACK_TO_LEGACY, + PASSWORD_TO_VERIFICATION, + PASSWORD_TO_ACCOUNT_SELECTOR, + PASSWORD_TO_PERSONAL_ACCOUNT, + ACCOUNT_SELECTOR_TO_DATA, + ACCOUNT_DATA_TO_VERIFICATION, + PERSONAL_OVERVIEW_TO_EMAIL, + TEAM_OVERVIEW_TO_EMAIL, + ACCOUNT_EMAIL_TO_LOGIN, + ACCOUNT_EMAIL_TO_DETAILS, + ACCOUNT_DETAILS_TO_CODE, + ACCOUNT_CODE_TO_SUMMARY, + ACCOUNT_SUMMARY_TO_USERNAME, + AUTH_TO_HOME, + AUTH_TO_INITIAL_SYNC, + AUTH_TO_E2EI, + AUTH_TO_REMOVE_DEVICE, + AUTH_TO_REGISTER_DEVICE, + AUTH_TO_USERNAME, + REGISTER_DEVICE_TO_REMOVE_DEVICE, + REGISTER_DEVICE_TO_E2EI, + REMOVE_DEVICE_TO_E2EI, + E2EI_TO_CERTIFICATE_DETAILS, + SESSION_SETUP_TO_HOME, + SESSION_SETUP_TO_INITIAL_SYNC, + HOME_TO_ADD_ACCOUNT, + ACCOUNT_SWITCH_TO_HOME, + ACCOUNT_SWITCH_TO_LOGIN, + TEAM_WEB_FLOW_TO_PASSWORD, + SESSION_POLICY, + ACTIVITY_EFFECT, + BACK, +} + +internal class AuthenticationNavigation3Router( + private val runtime: WireNavigation3Runtime, + private val transitionLedger: AuthenticationTransitionLedger = AuthenticationTransitionLedger(), +) { + /** + * Opens the login root requested by an Activity intent. + * + * Intents can be delivered again after Activity recreation. Replacing an already active, + * login root would create a second auth flow (and visibly compose Welcome/Login twice), + * so that transition is deliberately idempotent here at the mutation boundary. + */ + fun openLoginFromActivity(): Boolean { + if (runtime.navigator.routes.hasActiveAuthenticationFlow()) { + val transitionId = WireNavigationDiagnostics.nextTransitionId() + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = AuthenticationNavigationTransition.ACTIVITY_EFFECT.name, + outcome = "duplicate-current-login-ignored", + ) + return true + } + return navigate( + AuthenticationNavigationTransition.ACTIVITY_EFFECT, + NewLoginRoute.start(), + WireBackStackMode.CLEAR_WHOLE, + ) + } + + fun navigate( + transition: AuthenticationNavigationTransition, + destination: WireRoute, + mode: WireBackStackMode = WireBackStackMode.NONE, + ): Boolean = navigate( + transition = transition, + command = WireNavigationCommand(destination = destination, backStackMode = mode), + ) + + fun navigate( + transition: AuthenticationNavigationTransition, + command: WireNavigationCommand, + ): Boolean { + val transitionId = WireNavigationDiagnostics.nextTransitionId() + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = transition.name, + outcome = "requested:${command.destination.routeId}:${command.backStackMode}", + ) + return runtime.navigator.navigate(command).also { accepted -> + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = transition.name, + outcome = if (accepted) "accepted" else "rejected", + ) + } + } + + fun completeLogin(completion: AuthenticationLoginCompletion): Boolean = + when (completion) { + is AuthenticationLoginCompletion.Home -> navigate( + AuthenticationNavigationTransition.AUTH_TO_HOME, + HomeRoute(completion.sessionId), + WireBackStackMode.CLEAR_WHOLE, + ) + + is AuthenticationLoginCompletion.InitialSync -> navigate( + AuthenticationNavigationTransition.AUTH_TO_INITIAL_SYNC, + InitialSyncRoute(completion.sessionId), + WireBackStackMode.CLEAR_WHOLE, + ) + + is AuthenticationLoginCompletion.E2EIEnrollment -> navigate( + AuthenticationNavigationTransition.AUTH_TO_E2EI, + E2EIEnrollmentRoute( + sessionId = completion.sessionId, + flowId = currentAuthenticationFlowId(completion.sessionId), + ), + WireBackStackMode.CLEAR_WHOLE, + ) + + is AuthenticationLoginCompletion.RemoveDevice -> navigate( + AuthenticationNavigationTransition.AUTH_TO_REMOVE_DEVICE, + RemoveDeviceRoute( + sessionId = completion.sessionId, + flowId = currentAuthenticationFlowId(completion.sessionId), + ), + WireBackStackMode.CLEAR_WHOLE, + ) + } + + /** + * Applies a terminal login outcome once for the entry that produced [eventId]. + * + * A rejected navigation is deliberately not consumed and may be retried. + */ + fun completeLogin( + eventId: String, + completion: AuthenticationLoginCompletion, + ): Boolean = + when (transitionLedger.executeOnce(eventId) { completeLogin(completion) }) { + AuthenticationTransitionLedger.Outcome.APPLIED -> true + AuthenticationTransitionLedger.Outcome.REJECTED -> false + AuthenticationTransitionLedger.Outcome.ALREADY_APPLIED -> { + val transitionId = WireNavigationDiagnostics.nextTransitionId() + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = "LOGIN_TERMINAL", + outcome = "duplicate-ignored", + ) + true + } + } + + fun completeSessionSetup( + sessionId: WireSessionId, + destination: SessionSetupDestination, + ): Boolean = when (destination) { + SessionSetupDestination.HOME -> navigate( + AuthenticationNavigationTransition.SESSION_SETUP_TO_HOME, + HomeRoute(sessionId), + WireBackStackMode.CLEAR_WHOLE, + ) + + SessionSetupDestination.INITIAL_SYNC -> navigate( + AuthenticationNavigationTransition.SESSION_SETUP_TO_INITIAL_SYNC, + InitialSyncRoute(sessionId), + WireBackStackMode.CLEAR_WHOLE, + ) + } + + fun homeRequirement( + requirement: HomeRequirement, + currentSessionId: WireSessionId?, + ): Boolean = when (requirement) { + is HomeRequirement.RegisterDevice -> navigate( + AuthenticationNavigationTransition.AUTH_TO_REGISTER_DEVICE, + RegisterDeviceRoute(WireSessionId(requirement.userId.value, requirement.userId.domain)), + WireBackStackMode.CLEAR_WHOLE, + ) + + HomeRequirement.CreateAccountUsername -> navigate( + AuthenticationNavigationTransition.AUTH_TO_USERNAME, + CreateAccountUsernameRoute.start( + checkNotNull(currentSessionId) { + "$requirement requires the active session" + } + ), + WireBackStackMode.CLEAR_WHOLE, + ) + + HomeRequirement.InitialSync -> navigate( + AuthenticationNavigationTransition.AUTH_TO_INITIAL_SYNC, + InitialSyncRoute( + checkNotNull(currentSessionId) { + "InitialSync requires an active session" + } + ), + WireBackStackMode.CLEAR_WHOLE, + ) + } + + fun registerDeviceToE2EI(sessionId: WireSessionId, flowId: String): Boolean = navigate( + AuthenticationNavigationTransition.REGISTER_DEVICE_TO_E2EI, + E2EIEnrollmentRoute(sessionId = sessionId, flowId = flowId), + WireBackStackMode.CLEAR_WHOLE, + ) + + fun removeDeviceToE2EI(sessionId: WireSessionId, flowId: String): Boolean = navigate( + AuthenticationNavigationTransition.REMOVE_DEVICE_TO_E2EI, + E2EIEnrollmentRoute(sessionId = sessionId, flowId = flowId), + WireBackStackMode.CLEAR_WHOLE, + ) + + fun registerDeviceToRemoveDevice(sessionId: WireSessionId, flowId: String): Boolean = navigate( + AuthenticationNavigationTransition.REGISTER_DEVICE_TO_REMOVE_DEVICE, + RemoveDeviceRoute(sessionId = sessionId, flowId = flowId), + ) + + private fun currentAuthenticationFlowId(sessionId: WireSessionId): String = + runtime.navigator.currentRoute?.flowId ?: sessionId.authenticationSessionFlowId() + + fun backOrElse(onRoot: () -> Unit) { + val transitionId = WireNavigationDiagnostics.nextTransitionId() + val popped = runtime.navigator.goBack() + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = AuthenticationNavigationTransition.BACK.name, + outcome = if (popped) "popped" else "root-fallback", + ) + if (!popped) onRoot() + } + + fun replaceCurrent( + transition: AuthenticationNavigationTransition, + route: WireRoute, + ) { + val transitionId = WireNavigationDiagnostics.nextTransitionId() + val current = runtime.navigator.routes + check(current.isNotEmpty()) { + "Cannot replace an authentication route on an empty Navigation 3 stack" + } + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = transition.name, + outcome = "replace-current:${route.routeId}", + ) + runtime.navigator.replaceBackStack(current.dropLast(1) + route) + } + + val canNavigateBack: Boolean + get() = runtime.navigator.routes.size > 1 +} + +internal fun List.hasActiveAuthenticationFlow(): Boolean = + lastOrNull() is com.wire.navigation.AuthenticationRoute + +internal class AuthenticationTransitionLedger { + private val appliedEventIds = mutableSetOf() + + @Synchronized + fun executeOnce( + eventId: String, + action: () -> Boolean, + ): Outcome { + require(eventId.isNotBlank()) { "Authentication transition event id cannot be blank" } + if (eventId in appliedEventIds) return Outcome.ALREADY_APPLIED + return if (action()) { + appliedEventIds += eventId + Outcome.APPLIED + } else { + Outcome.REJECTED + } + } + + enum class Outcome { + APPLIED, + REJECTED, + ALREADY_APPLIED, + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutes.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutes.kt new file mode 100644 index 00000000000..50fa02cddfe --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutes.kt @@ -0,0 +1,247 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.WireNavEntryId +import kotlinx.serialization.Serializable + +/** + * Navigation 3 contracts for the authentication entry points. + * + * The route ids intentionally preserve the former generated `baseRoute` values so existing screen + * analytics remain stable. + * Arguments are expressed only as serializable value DTOs so the contracts can move to common + * code without carrying Android, Compose Destinations, or Kalium implementation types with them. + */ +@Serializable +data class WelcomeChooserRoute( + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/welcome_chooser_screen" + } +} + +@Serializable +data class NewWelcomeEmptyStartRoute( + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/new_welcome_empty_start_screen" + } +} + +@Serializable +data class WelcomeRoute( + val customServerConfig: AuthenticationServerLinks? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), + override val flowId: String = entryId.value, +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A legacy-login flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/welcome_screen" + } +} + +@Serializable +data class LoginRoute( + val args: AuthenticationLoginArguments = AuthenticationLoginArguments(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), + override val flowId: String = entryId.value, +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A legacy-login flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/login_screen" + } +} + +@Serializable +data class NewLoginRoute( + val args: AuthenticationLoginArguments = AuthenticationLoginArguments(), + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A new-login flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/new_login_screen" + + fun start( + args: AuthenticationLoginArguments = AuthenticationLoginArguments(), + ): NewLoginRoute { + val entryId = WireNavEntryId.random() + return NewLoginRoute(args = args, flowId = entryId.value, entryId = entryId) + } + } +} + +@Serializable +data class NewLoginPasswordRoute( + val args: AuthenticationLoginArguments, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A new-login flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/new_login_password_screen" + } +} + +@Serializable +data class NewLoginVerificationCodeRoute( + val args: AuthenticationLoginArguments, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A new-login flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/new_login_verification_code_screen" + } +} + +@Serializable +data class AuthenticationLoginArguments( + val userHandle: AuthenticationPrefilledUserIdentifier? = null, + val ssoLoginResult: AuthenticationSsoLoginResult? = null, + val loginPasswordPath: AuthenticationLoginPasswordPath? = null, + val ssoCodeAutoLogin: AuthenticationSsoCodeAutoLogin? = null, + val showBackendConfigSuccess: Boolean = false, +) + +@Serializable +data class AuthenticationPrefilledUserIdentifier( + val value: String, + val editable: Boolean = false, +) + +@Serializable +sealed interface AuthenticationSsoLoginResult { + @Serializable + data class Success( + val cookie: String, + val serverConfigId: String, + ) : AuthenticationSsoLoginResult + + @Serializable + data class Failure( + val code: AuthenticationSsoFailureCode, + ) : AuthenticationSsoLoginResult +} + +@Serializable +enum class AuthenticationSsoFailureCode { + SERVER_ERROR_UNSUPPORTED_SAML, + BAD_SUCCESS_REDIRECT, + BAD_FAILURE_REDIRECT, + BAD_USERNAME, + BAD_UPSTREAM, + SERVER_ERROR, + NOT_FOUND, + FORBIDDEN, + NO_MATCHING_AUTH_REQUEST, + INSUFFICIENT_PERMISSIONS, + UNKNOWN, +} + +@Serializable +data class AuthenticationSsoCodeAutoLogin( + val ssoCode: String, + val autoInitiateLogin: Boolean = true, + val nomadServiceUrl: String? = null, + val cookieLabel: String? = null, +) + +@Serializable +data class AuthenticationLoginPasswordPath( + val customServerConfig: AuthenticationServerLinks? = null, + val isCloudAccountCreationPossible: Boolean? = null, + val domainClaim: AuthenticationDomainClaim = AuthenticationDomainClaim.NotClaimed, +) + +@Serializable +sealed interface AuthenticationDomainClaim { + @Serializable + data object NotClaimed : AuthenticationDomainClaim + + @Serializable + data class Claimed( + val domain: String, + ) : AuthenticationDomainClaim { + init { + require(domain.isNotBlank()) { "A claimed authentication domain cannot be blank" } + } + } +} + +@Serializable +data class AuthenticationServerLinks( + val api: String, + val accounts: String, + val webSocket: String, + val blackList: String, + val teams: String, + val website: String, + val title: String, + val isOnPremises: Boolean, + val apiProxy: AuthenticationApiProxy?, + val supportEmail: String? = null, +) + +@Serializable +data class AuthenticationApiProxy( + val needsAuthentication: Boolean, + val host: String, + val port: Int, +) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountLegacyMappers.kt new file mode 100644 index 00000000000..20887e99891 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountLegacyMappers.kt @@ -0,0 +1,111 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +@file:Suppress("TooManyFunctions") + +package com.wire.android.navigation.routes.auth + +import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs +import com.wire.android.ui.authentication.create.common.CreateAccountFlowType +import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs +import com.wire.android.ui.authentication.create.common.UserRegistrationInfo +import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewNavArgs +import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryNavArgs +import com.wire.android.ui.registration.selector.CreateAccountSelectorNavArgs +import com.wire.navigation.WireSessionId + +internal fun CreateAccountSelectorRoute.toLegacyNavArgs(): CreateAccountSelectorNavArgs = + CreateAccountSelectorNavArgs(customServerConfig?.toLegacy(), email) + +internal fun CreateAccountDataDetailRoute.toLegacyNavArgs(): CreateAccountDataNavArgs = + CreateAccountDataNavArgs(registrationInfo.toLegacy(), customServerConfig?.toLegacy()) + +internal fun CreateAccountVerificationCodeRoute.toLegacyNavArgs(): CreateAccountDataNavArgs = + CreateAccountDataNavArgs(registrationInfo.toLegacy(), customServerConfig?.toLegacy()) + +internal fun CreatePersonalAccountOverviewRoute.toLegacyNavArgs(): CreateAccountOverviewNavArgs = + CreateAccountOverviewNavArgs(customServerConfig?.toLegacy()) + +internal fun CreateTeamAccountOverviewRoute.toLegacyNavArgs(): CreateAccountOverviewNavArgs = + CreateAccountOverviewNavArgs(customServerConfig?.toLegacy()) + +internal fun CreateAccountEmailRoute.toLegacyNavArgs(): CreateAccountNavArgs = + CreateAccountNavArgs(type.toLegacy(), registrationInfo.toLegacy(), customServerConfig?.toLegacy()) + +internal fun CreateAccountDetailsRoute.toLegacyNavArgs(): CreateAccountNavArgs = + CreateAccountNavArgs(type.toLegacy(), registrationInfo.toLegacy(), customServerConfig?.toLegacy()) + +internal fun CreateAccountCodeRoute.toLegacyNavArgs(): CreateAccountNavArgs = + CreateAccountNavArgs(type.toLegacy(), registrationInfo.toLegacy(), customServerConfig?.toLegacy()) + +internal fun CreateAccountSummaryRoute.toLegacyNavArgs(): CreateAccountSummaryNavArgs = + CreateAccountSummaryNavArgs(type.toLegacy()) + +internal fun CreateAccountSummaryNavArgs.toSummaryRoute( + flowId: String, + sessionId: WireSessionId, +): CreateAccountSummaryRoute = + CreateAccountSummaryRoute(type.toRoute(), sessionId, flowId) + +internal fun CreateAccountDataNavArgs.toDataDetailRoute(flowId: String): CreateAccountDataDetailRoute = + CreateAccountDataDetailRoute( + registrationInfo = userRegistrationInfo.toRoute(), + customServerConfig = customServerConfig?.toAuthenticationServerLinks(), + flowId = flowId, + ) + +internal fun CreateAccountDataNavArgs.toVerificationCodeRoute(flowId: String): CreateAccountVerificationCodeRoute = + CreateAccountVerificationCodeRoute( + registrationInfo = userRegistrationInfo.toRoute(), + customServerConfig = customServerConfig?.toAuthenticationServerLinks(), + flowId = flowId, + ) + +internal fun CreateAccountNavArgs.toEmailRoute(flowId: String): CreateAccountEmailRoute = + CreateAccountEmailRoute( + type = flowType.toRoute(), + registrationInfo = userRegistrationInfo.toRoute(), + customServerConfig = customServerConfig?.toAuthenticationServerLinks(), + flowId = flowId, + ) + +internal fun CreateAccountNavArgs.toDetailsRoute(flowId: String): CreateAccountDetailsRoute = + CreateAccountDetailsRoute( + type = flowType.toRoute(), + registrationInfo = userRegistrationInfo.toRoute(), + customServerConfig = customServerConfig?.toAuthenticationServerLinks(), + flowId = flowId, + ) + +internal fun CreateAccountNavArgs.toCodeRoute(flowId: String): CreateAccountCodeRoute = + CreateAccountCodeRoute( + type = flowType.toRoute(), + registrationInfo = userRegistrationInfo.toRoute(), + customServerConfig = customServerConfig?.toAuthenticationServerLinks(), + flowId = flowId, + ) + +private fun CreateAccountFlowType.toRoute(): CreateAccountRouteFlowType = + when (this) { + CreateAccountFlowType.CreatePersonalAccount -> CreateAccountRouteFlowType.PERSONAL + CreateAccountFlowType.CreateTeam -> CreateAccountRouteFlowType.TEAM + } + +private fun CreateAccountRouteFlowType.toLegacy(): CreateAccountFlowType = + when (this) { + CreateAccountRouteFlowType.PERSONAL -> CreateAccountFlowType.CreatePersonalAccount + CreateAccountRouteFlowType.TEAM -> CreateAccountFlowType.CreateTeam + } + +private fun UserRegistrationInfo.toRoute(): CreateAccountRegistrationInfo = + CreateAccountRegistrationInfo(email, name, firstName, lastName, password, teamName, teamIcon) + +private fun CreateAccountRegistrationInfo.toLegacy(): UserRegistrationInfo = + UserRegistrationInfo(email, name, firstName, lastName, password, teamName, teamIcon) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt new file mode 100644 index 00000000000..295e0b23e27 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt @@ -0,0 +1,224 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +enum class CreateAccountRouteFlowType { + PERSONAL, + TEAM, +} + +@Serializable +data class CreateAccountRegistrationInfo( + val email: String = "", + val name: String = "", + val firstName: String = "", + val lastName: String = "", + val password: String = "", + val teamName: String = "", + val teamIcon: String = "default", +) + +@Serializable +data class CreateAccountSelectorRoute( + val customServerConfig: AuthenticationServerLinks? = null, + val email: String? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_selector_screen" + } +} + +@Serializable +data class CreateAccountDataDetailRoute( + val registrationInfo: CreateAccountRegistrationInfo = CreateAccountRegistrationInfo(), + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_data_detail_screen" + } +} + +@Serializable +data class CreateAccountVerificationCodeRoute( + val registrationInfo: CreateAccountRegistrationInfo, + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_verification_code_screen" + } +} + +@Serializable +data class CreatePersonalAccountOverviewRoute( + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_personal_account_overview_screen" + } +} + +@Serializable +data class CreateTeamAccountOverviewRoute( + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_team_account_overview_screen" + } +} + +@Serializable +data class CreateAccountEmailRoute( + val type: CreateAccountRouteFlowType, + val registrationInfo: CreateAccountRegistrationInfo = CreateAccountRegistrationInfo(), + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_email_screen" + } +} + +@Serializable +data class CreateAccountDetailsRoute( + val type: CreateAccountRouteFlowType, + val registrationInfo: CreateAccountRegistrationInfo, + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_details_screen" + } +} + +@Serializable +data class CreateAccountCodeRoute( + val type: CreateAccountRouteFlowType, + val registrationInfo: CreateAccountRegistrationInfo, + val customServerConfig: AuthenticationServerLinks? = null, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_code_screen" + } +} + +@Serializable +data class CreateAccountSummaryRoute( + val type: CreateAccountRouteFlowType, + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_summary_screen" + } +} + +@Serializable +data class CreateAccountUsernameRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A create-account flow id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/create_account_username_screen" + + fun start(sessionId: WireSessionId): CreateAccountUsernameRoute { + val entryId = WireNavEntryId.random() + return CreateAccountUsernameRoute( + sessionId = sessionId, + flowId = entryId.value, + entryId = entryId, + ) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt new file mode 100644 index 00000000000..08e3b9755bc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt @@ -0,0 +1,299 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModelStoreOwner +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.ui.authentication.create.code.CreateAccountCodeRouteScreen +import com.wire.android.ui.authentication.create.details.CreateAccountDetailsRouteScreen +import com.wire.android.ui.authentication.create.email.CreateAccountEmailRouteScreen +import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewRouteScreen +import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryRouteScreen +import com.wire.android.ui.authentication.create.username.CreateAccountUsernameRouteScreen +import com.wire.android.ui.authentication.create.common.CreateAccountFlowType +import com.wire.android.ui.authentication.createAccountCodeViewModel +import com.wire.android.ui.authentication.createAccountDataDetailViewModel +import com.wire.android.ui.authentication.createAccountDetailsViewModel +import com.wire.android.ui.authentication.createAccountEmailViewModel +import com.wire.android.ui.authentication.createAccountOverviewViewModel +import com.wire.android.ui.authentication.createAccountSelectorViewModel +import com.wire.android.ui.authentication.createAccountSummaryViewModel +import com.wire.android.ui.authentication.createAccountUsernameViewModel +import com.wire.android.ui.authentication.createAccountVerificationCodeViewModel +import com.wire.android.ui.authentication.login.LoginNavArgs +import com.wire.android.ui.authentication.login.LoginPasswordPath +import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType +import com.wire.android.ui.registration.code.CreateAccountVerificationCodeRouteScreen +import com.wire.android.ui.registration.details.CreateAccountDataDetailRouteScreen +import com.wire.android.ui.registration.selector.CreateAccountSelectorRouteScreen +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireViewModelOwner + +internal fun createAccountNavigation3Entries( + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + CreateAccountSelectorNavigation3Entry(route, actions, router) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + CreateAccountDataDetailNavigation3Entry(route, actions, router) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + CreateAccountVerificationNavigation3Entry(route, actions, router) + } + wireEntry { route -> + CreateAccountOverviewNavigation3Entry(route, CreateAccountFlowType.CreatePersonalAccount, actions, router) + } + wireEntry { route -> + CreateAccountOverviewNavigation3Entry(route, CreateAccountFlowType.CreateTeam, actions, router) + } + wireEntry { route -> + CreateAccountEmailNavigation3Entry(route, actions, router) + } + wireEntry { route -> + CreateAccountDetailsNavigation3Entry(route, actions, router) + } + wireEntry { route -> + CreateAccountCodeNavigation3Entry(route, actions, router) + } + wireEntry { route -> + CreateAccountSummaryNavigation3Entry(route, router) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + CreateAccountUsernameNavigation3Entry(route, router) + } +} + +@Composable +private fun CreateAccountSelectorNavigation3Entry( + route: CreateAccountSelectorRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + val viewModel = createAccountSelectorViewModel(route.toLegacyNavArgs(), owner) + CreateAccountSelectorRouteScreen( + viewModel = viewModel, + onPersonalAccountCreation = { + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_SELECTOR_TO_DATA, + it.toDataDetailRoute(route.flowId), + ) + }, + onTeamAccountCreation = { url -> + val returnRoute = LoginNavArgs( + userHandle = PreFilledUserIdentifierType.PreFilled(viewModel.email), + loginPasswordPath = LoginPasswordPath(customServerConfig = viewModel.serverConfig), + ).toNewLoginPasswordRoute(route.flowId) + actions.openTeamAccountCreation( + AuthenticationTeamAccountCreationRequest(url, returnRoute) + ) + }, + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + ) +} + +@Composable +private fun CreateAccountDataDetailNavigation3Entry( + route: CreateAccountDataDetailRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountDataDetailRouteScreen( + viewModel = createAccountDataDetailViewModel(route.toLegacyNavArgs(), owner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onCodeRequested = { + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_DATA_TO_VERIFICATION, + it.toVerificationCodeRoute(route.flowId), + ) + }, + ) +} + +@Composable +private fun CreateAccountVerificationNavigation3Entry( + route: CreateAccountVerificationCodeRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountVerificationCodeRouteScreen( + viewModel = createAccountVerificationCodeViewModel(route.toLegacyNavArgs(), owner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onSuccess = { userId -> + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_SUMMARY_TO_USERNAME, + CreateAccountUsernameRoute(userId.toWireSessionId(), route.flowId), + WireBackStackMode.CLEAR_WHOLE, + ) + }, + onTooManyDevices = { + router.completeLogin( + route.terminalLoginEventId(), + AuthenticationLoginCompletion.RemoveDevice(it.toWireSessionId()), + ) + }, + ) +} + +@Composable +private fun CreateAccountOverviewNavigation3Entry( + route: com.wire.navigation.AuthenticationRoute, + flowType: CreateAccountFlowType, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val flowId = checkNotNull(route.flowId) + val owner = createAccountEntryOwner(route.entryId) + val navArgs = when (route) { + is CreatePersonalAccountOverviewRoute -> route.toLegacyNavArgs() + is CreateTeamAccountOverviewRoute -> route.toLegacyNavArgs() + else -> error("Unsupported create-account overview route ${route::class.qualifiedName}") + } + CreateAccountOverviewRouteScreen( + flowType = flowType, + viewModel = createAccountOverviewViewModel(navArgs, owner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onContinue = { + router.navigate( + if (flowType == CreateAccountFlowType.CreatePersonalAccount) { + AuthenticationNavigationTransition.PERSONAL_OVERVIEW_TO_EMAIL + } else { + AuthenticationNavigationTransition.TEAM_OVERVIEW_TO_EMAIL + }, + it.toEmailRoute(flowId), + ) + }, + ) +} + +@Composable +private fun CreateAccountEmailNavigation3Entry( + route: CreateAccountEmailRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountEmailRouteScreen( + viewModel = createAccountEmailViewModel(route.toLegacyNavArgs(), owner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onLogin = { + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_EMAIL_TO_LOGIN, + LoginRoute(flowId = route.flowId), + WireBackStackMode.CLEAR_TILL_START, + ) + }, + onDetailsRequested = { + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_EMAIL_TO_DETAILS, + it.toDetailsRoute(route.flowId), + ) + }, + ) +} + +@Composable +private fun CreateAccountDetailsNavigation3Entry( + route: CreateAccountDetailsRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountDetailsRouteScreen( + viewModel = createAccountDetailsViewModel(route.toLegacyNavArgs(), owner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onCodeRequested = { + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_DETAILS_TO_CODE, + it.toCodeRoute(route.flowId), + ) + }, + ) +} + +@Composable +private fun CreateAccountCodeNavigation3Entry( + route: CreateAccountCodeRoute, + actions: AuthenticationNavigation3Actions, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountCodeRouteScreen( + viewModel = createAccountCodeViewModel(route.toLegacyNavArgs(), owner), + onNavigateBack = { router.backOrElse(actions::exitAuthentication) }, + onSuccess = { summaryArgs, userId -> + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_CODE_TO_SUMMARY, + summaryArgs.toSummaryRoute(route.flowId, userId.toWireSessionId()), + WireBackStackMode.CLEAR_WHOLE, + ) + }, + onTooManyDevices = { + router.completeLogin( + route.terminalLoginEventId(), + AuthenticationLoginCompletion.RemoveDevice(it.toWireSessionId()), + ) + }, + ) +} + +@Composable +private fun CreateAccountSummaryNavigation3Entry( + route: CreateAccountSummaryRoute, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountSummaryRouteScreen( + viewModel = createAccountSummaryViewModel(route.toLegacyNavArgs(), owner), + onContinue = { + router.navigate( + AuthenticationNavigationTransition.ACCOUNT_SUMMARY_TO_USERNAME, + CreateAccountUsernameRoute(route.sessionId, route.flowId), + WireBackStackMode.CLEAR_WHOLE, + ) + }, + ) +} + +@Composable +private fun CreateAccountUsernameNavigation3Entry( + route: CreateAccountUsernameRoute, + router: AuthenticationNavigation3Router, +) { + val owner = createAccountEntryOwner(route.entryId) + CreateAccountUsernameRouteScreen( + viewModel = createAccountUsernameViewModel(owner), + onSuccess = { + router.completeLogin( + route.terminalLoginEventId(), + AuthenticationLoginCompletion.InitialSync(route.sessionId), + ) + }, + ) +} + +@Composable +private fun createAccountEntryOwner(entryId: com.wire.navigation.WireNavEntryId): ViewModelStoreOwner = + wireViewModelStoreOwner(WireViewModelOwner.Entry(entryId)) + +private fun com.wire.kalium.logic.data.user.UserId.toWireSessionId() = + com.wire.navigation.WireSessionId(value, domain) + +private fun com.wire.navigation.WireRoute.terminalLoginEventId(): String = + "${entryId.value}:login-terminal" diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt new file mode 100644 index 00000000000..8fe153c6165 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt @@ -0,0 +1,211 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ +package com.wire.android.navigation.routes.media + +import android.net.Uri +import androidx.compose.runtime.Composable +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.mediaplayer.VideoPlayer +import com.wire.android.ui.home.FeatureFlagState +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.checkAssetRestrictionsViewModel +import com.wire.android.ui.home.conversations.conversationAssetMessagesViewModel +import com.wire.android.ui.home.conversations.conversationMessagesViewModel +import com.wire.android.ui.home.conversations.imagesPreviewViewModel +import com.wire.android.ui.home.conversations.media.ConversationMediaRouteScreen +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewRouteScreen +import com.wire.android.ui.home.conversations.messageDetailsViewModel +import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsRouteScreen +import com.wire.android.ui.home.conversations.mediaGalleryViewModel +import com.wire.android.ui.home.gallery.MediaGalleryActionType +import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs +import com.wire.android.ui.home.gallery.MediaGalleryRouteScreen +import com.wire.android.ui.home.featureFlagNotificationViewModel +import com.wire.android.ui.importMediaAuthenticatedViewModel +import com.wire.android.ui.sharing.ImportMediaAuthenticatedContent +import com.wire.android.ui.sharing.ImportMediaLoggedOutContent +import com.wire.android.ui.sharing.ImportMediaLoadingContent +import com.wire.navigation.WireNavResult + +internal val ImagesPreviewNavigation3ResultType = WireNavigation3ResultType( + ImagesPreviewResultContract, + ImagesPreviewResult.serializer(), +) +internal val MediaGalleryNavigation3ResultType = WireNavigation3ResultType( + MediaGalleryResultContract, + MediaGalleryResult.serializer(), +) + +internal interface MediaNavigation3Actions { + fun finishShare() + fun openAuthentication() + fun openConversationFromShare( + conversationId: MediaConversationId, + assets: List, + text: String?, + ) + fun openPublicLink(assetId: String, fileName: String, publicLinkId: String?) +} + +internal object MediaNavigation3Contribution { + val resultTypes: List> = + listOf(ImagesPreviewNavigation3ResultType, MediaGalleryNavigation3ResultType) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: MediaNavigation3Actions, + ): List = listOf(mediaNavigation3Entries(runtime, actions)) +} + +@Suppress("LongMethod") +internal fun mediaNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: MediaNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + ConversationMediaRouteScreen( + conversationAssetMessagesViewModel(route.toViewModelArgs()), + conversationMessagesViewModel(ConversationNavArgs(route.conversationId.toQualifiedId())), + runtime.navigator::goBack, + onShareAssetViaWire = { uri -> + runtime.navigator.navigate( + com.wire.navigation.WireNavigationCommand( + AuthenticatedImportMediaRoute(route.sessionId, listOf(uri.toString())) + ) + ) + }, + ) { conversationId, messageId, selfAsset, cellAssetId -> + runtime.navigator.navigate( + com.wire.navigation.WireNavigationCommand( + MediaGalleryRoute( + route.sessionId, + conversationId.toMediaConversationId(), + messageId, + selfAsset, + false, + false, + cellAssetId, + ) + ) + ) + } + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + ImagesPreviewRouteScreen( + imagesPreviewViewModel(route.toViewModelArgs()), + checkAssetRestrictionsViewModel(), + runtime.navigator::goBack, + ) { assets -> + if (!runtime.completeCurrentAndPop( + ImagesPreviewNavigation3ResultType, + WireNavResult.Value(ImagesPreviewResult(assets.map { it.toNavigationDto() })), + ) + ) { + runtime.navigator.goBack() + } + } + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + MediaGalleryNavigation3Entry(route, runtime, actions) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + VideoPlayer( + localPath = route.localPath, + contentUrl = route.contentUrl, + fileName = route.fileName, + onNavigateBack = runtime.navigator::goBack, + ) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + MessageDetailsRouteScreen(messageDetailsViewModel(route.toViewModelArgs()), runtime.navigator::goBack) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + ImportMediaLoggedOutContent( + FeatureFlagState.FileSharingState.NoUser, + actions::finishShare, + actions::openAuthentication, + ) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + AuthenticatedImportMediaNavigation3Entry(route, actions) + } +} + +@Composable +private fun AuthenticatedImportMediaNavigation3Entry( + route: AuthenticatedImportMediaRoute, + actions: MediaNavigation3Actions, +) { + when (val state = featureFlagNotificationViewModel().featureFlagState.isFileSharingState) { + FeatureFlagState.FileSharingState.Loading -> ImportMediaLoadingContent(actions::finishShare) + FeatureFlagState.FileSharingState.NoUser -> ImportMediaLoggedOutContent( + state, + actions::finishShare, + actions::openAuthentication, + ) + FeatureFlagState.FileSharingState.DisabledByTeam, + FeatureFlagState.FileSharingState.AllowAll, + is FeatureFlagState.FileSharingState.AllowSome, + -> ImportMediaAuthenticatedContent( + isRestrictedInTeam = state == FeatureFlagState.FileSharingState.DisabledByTeam, + navigateBack = actions::finishShare, + onConversationReady = { conversationId, assets, text -> + actions.openConversationFromShare( + conversationId.toMediaConversationId(), + assets.map { it.toNavigationDto() }, + text, + ) + }, + internalAssetUris = route.internalAssetUriStrings.map(Uri::parse), + importMediaViewModel = importMediaAuthenticatedViewModel(), + ) + } +} + +@Composable +private fun MediaGalleryNavigation3Entry( + route: MediaGalleryRoute, + runtime: WireNavigation3Runtime, + actions: MediaNavigation3Actions, +) { + MediaGalleryRouteScreen( + mediaGalleryViewModel(route.toViewModelArgs()), + runtime.navigator::goBack, + onResult = { result -> + val typed = result.toNavigationResult() + if (!runtime.completeCurrentAndPop( + MediaGalleryNavigation3ResultType, + WireNavResult.Value(typed), + ) + ) { + runtime.navigator.goBack() + } + }, + onOpenPublicLink = actions::openPublicLink, + onShareAssetViaWire = { uri -> + runtime.navigator.navigate( + com.wire.navigation.WireNavigationCommand( + AuthenticatedImportMediaRoute(route.sessionId, listOf(uri.toString())) + ) + ) + }, + ) +} + +private fun MediaGalleryNavBackArgs.toNavigationResult() = MediaGalleryResult( + messageId, + emoji, + isSelfAsset, + when (mediaGalleryActionType) { + MediaGalleryActionType.REPLY -> MediaGalleryResultAction.REPLY + MediaGalleryActionType.REACT -> MediaGalleryResultAction.REACT + MediaGalleryActionType.DETAIL -> MediaGalleryResultAction.DETAIL + }, + cellAssetId, +) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Mappers.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Mappers.kt new file mode 100644 index 00000000000..3dce894dfb3 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Mappers.kt @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ +package com.wire.android.navigation.routes.media + +import android.net.Uri +import com.wire.android.ui.home.conversations.media.ConversationMediaNavArgs +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavArgs +import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsNavArgs +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.ui.home.gallery.MediaGalleryNavArgs +import com.wire.kalium.logic.data.asset.AttachmentType +import com.wire.kalium.logic.data.id.QualifiedID +import okio.Path.Companion.toPath + +internal fun MediaConversationId.toQualifiedId() = QualifiedID(value, domain) +internal fun QualifiedID.toMediaConversationId() = MediaConversationId(value, domain) + +internal fun ConversationMediaRoute.toViewModelArgs() = ConversationMediaNavArgs(conversationId.toQualifiedId()) +internal fun ImagesPreviewRoute.toViewModelArgs() = ImagesPreviewNavArgs( + conversationId.toQualifiedId(), + conversationName, + ArrayList(assetUris.map(Uri::parse)), +) +internal fun MediaGalleryRoute.toViewModelArgs() = MediaGalleryNavArgs( + conversationId.toQualifiedId(), + messageId, + isSelfAsset, + isEphemeral, + messageOptionsEnabled, + cellAssetId, +) +internal fun MessageDetailsRoute.toViewModelArgs() = + MessageDetailsNavArgs(conversationId.toQualifiedId(), messageId, isSelfMessage) + +internal fun AssetBundle.toNavigationDto() = MediaAssetDto( + key, + mimeType, + dataPath.toString(), + dataSize, + fileName, + assetType.name, + audioWavesMask +) +internal fun MediaAssetDto.toLegacy() = AssetBundle( + key, + mimeType, + dataPath.toPath(), + dataSize, + fileName, + AttachmentType.valueOf(assetType), + audioWavesMask +) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt new file mode 100644 index 00000000000..b1e29bd649b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaRoutes.kt @@ -0,0 +1,145 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.media + +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class MediaConversationId(val value: String, val domain: String) { + init { + require(value.isNotBlank()) + require(domain.isNotBlank()) + } +} + +@Serializable +data class ConversationMediaRoute( + override val sessionId: WireSessionId, + val conversationId: MediaConversationId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/conversation_media_screen" } +} + +@Serializable +data class ImagesPreviewRoute( + override val sessionId: WireSessionId, + val conversationId: MediaConversationId, + val conversationName: String, + val assetUris: List, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/images_preview_screen" } +} + +@Serializable +data class MediaGalleryRoute( + override val sessionId: WireSessionId, + val conversationId: MediaConversationId, + val messageId: String, + val isSelfAsset: Boolean, + val isEphemeral: Boolean, + val messageOptionsEnabled: Boolean, + val cellAssetId: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/media_gallery_screen" } +} + +@Serializable +data class VideoPlayerRoute( + override val sessionId: WireSessionId, + val localPath: String?, + val contentUrl: String?, + val fileName: String?, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/video_player_screen" } +} + +@Serializable +data class MessageDetailsRoute( + override val sessionId: WireSessionId, + val conversationId: MediaConversationId, + val messageId: String, + val isSelfMessage: Boolean, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/message_details_screen" } +} + +/** + * Explicit logged-out share entry. Its type always resolves to the authentication Metro graph. + */ +@Serializable +data class LoggedOutImportMediaRoute( + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : AuthenticationRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "app/import_media_screen" } +} + +/** + * Explicit authenticated share entry. It can only resolve to one concrete session Metro graph. + */ +@Serializable +data class AuthenticatedImportMediaRoute( + override val sessionId: WireSessionId, + val internalAssetUriStrings: List = emptyList(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = LoggedOutImportMediaRoute.ROUTE_ID +} + +@Serializable +data class MediaAssetDto( + val key: String, + val mimeType: String, + val dataPath: String, + val dataSize: Long, + val fileName: String, + val assetType: String, + val audioWavesMask: List? = null, +) + +@Serializable +data class ImagesPreviewResult(val assets: List) + +internal val ImagesPreviewResultContract = WireNavResultContract( + WireNavResultContractId("media.images-preview") +) + +@Serializable +enum class MediaGalleryResultAction { REPLY, REACT, DETAIL } + +@Serializable +data class MediaGalleryResult( + val messageId: String, + val emoji: String? = null, + val isSelfAsset: Boolean, + val action: MediaGalleryResultAction, + val cellAssetId: String? = null, +) + +internal val MediaGalleryResultContract = WireNavResultContract( + WireNavResultContractId("media.gallery") +) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3Entries.kt new file mode 100644 index 00000000000..fa47217c04c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3Entries.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.utility + +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.routes.media.AuthenticatedImportMediaRoute +import com.wire.android.ui.debug.DebugRouteScreen +import com.wire.android.ui.debug.LogManagementRouteScreen +import com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsRouteScreen +import com.wire.android.ui.debug.featureflags.DebugFeatureFlagsRouteScreen +import com.wire.android.ui.initialsync.InitialSyncRouteScreen +import com.wire.navigation.WireNavigationCommand + +internal fun interface InitialSyncNavigation3Actions { + fun completeInitialSync(route: InitialSyncRoute, shouldMoveToBackground: Boolean) +} + +internal object UtilityNavigation3Contribution { + val resultTypes = emptyList>() + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: InitialSyncNavigation3Actions, + ): List = + listOf(utilityNavigation3Entries(runtime, actions)) +} + +internal fun utilityNavigation3Entries( + runtime: WireNavigation3Runtime, + initialSyncActions: InitialSyncNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.None) { route -> + InitialSyncRouteScreen { shouldMoveToBackground -> + initialSyncActions.completeInitialSync(route, shouldMoveToBackground) + } + } + wireEntry { route -> + DebugRouteScreen( + onBack = runtime.navigator::goBack, + onShowFeatureFlags = { + runtime.navigator.navigate( + WireNavigationCommand(DebugFeatureFlagsRoute(route.sessionId)) + ) + }, + onShowCryptoStats = { + runtime.navigator.navigate( + WireNavigationCommand(ConversationCryptoStatsRoute(route.sessionId)) + ) + }, + onShareLogsViaWire = { uri -> + runtime.navigator.navigate( + WireNavigationCommand( + AuthenticatedImportMediaRoute(route.sessionId, listOf(uri.toString())) + ) + ) + }, + ) + } + wireEntry { route -> + LogManagementRouteScreen( + onBack = runtime.navigator::goBack, + onShareLogsViaWire = { uri -> + runtime.navigator.navigate( + WireNavigationCommand( + AuthenticatedImportMediaRoute(route.sessionId, listOf(uri.toString())) + ) + ) + }, + ) + } + wireEntry { + DebugFeatureFlagsRouteScreen(onBack = runtime.navigator::goBack) + } + wireEntry { + ConversationCryptoStatsRouteScreen(onBack = runtime.navigator::goBack) + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutes.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutes.kt new file mode 100644 index 00000000000..55c1fee9c36 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutes.kt @@ -0,0 +1,82 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.utility + +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class InitialSyncRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/initial_sync_screen" + } +} + +@Serializable +data class DebugRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/debug_screen" + } +} + +@Serializable +data class LogManagementRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/log_management_screen" + } +} + +@Serializable +data class DebugFeatureFlagsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/debug_feature_flags_screen" + } +} + +@Serializable +data class ConversationCryptoStatsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/conversation_crypto_stats_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/MetroWireEntryEnvironment.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/MetroWireEntryEnvironment.kt new file mode 100644 index 00000000000..3d281d927c2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/MetroWireEntryEnvironment.kt @@ -0,0 +1,177 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import com.wire.android.di.metro.AppAuthenticationViewModelGraph +import com.wire.android.di.metro.AppSessionViewModelGraph +import com.wire.android.di.metro.MetroViewModelGraph +import com.wire.android.di.metro.WireApplicationGraph +import com.wire.android.model.LocalWireSessionImageLoader +import com.wire.android.navigation.navigation3.WireEntryEnvironment +import com.wire.android.ui.authentication.LocalAuthenticationCancelUserId +import com.wire.android.ui.userprofile.self.LocalSelfUserProfileLogoutAction +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory + +/** + * The Metro dependencies that are safe to expose to one Navigation 3 entry. + */ +internal data class MetroWireEntryGraph( + val graph: MetroViewModelGraph, + val viewModelFactory: MetroViewModelFactory, + val sessionGraph: AppSessionViewModelGraph?, +) + +internal sealed interface MetroWireEntryGraphResolution { + + data class Available(val entryGraph: MetroWireEntryGraph) : MetroWireEntryGraphResolution + + data class Unavailable(val routeScope: WireRouteScope.Unavailable) : MetroWireEntryGraphResolution +} + +/** + * Resolves a typed route to the graph owning that route. + * + * Authentication entries deliberately use the application factory, matching the existing + * WireActivity behavior. Session entries use the factory from the lifecycle-retained graph of the + * account encoded by the route itself. + */ +internal class MetroWireEntryGraphResolver( + private val appGraph: WireApplicationGraph, + private val authenticationGraph: AppAuthenticationViewModelGraph, + private val sessionGraphStore: SessionGraphStoreViewModel, +) { + + fun resolve(route: WireRoute): MetroWireEntryGraphResolution = + when (val routeScope = WireRouteScopeResolver.resolve(route)) { + WireRouteScope.Authentication -> MetroWireEntryGraphResolution.Available( + MetroWireEntryGraph( + graph = authenticationGraph, + viewModelFactory = appGraph.metroViewModelFactory, + sessionGraph = null, + ) + ) + + is WireRouteScope.Session -> { + val userId = routeScope.sessionId.toKaliumUserId() + try { + val sessionGraph = sessionGraphStore.graphFor(userId) + MetroWireEntryGraphResolution.Available( + MetroWireEntryGraph( + graph = sessionGraph, + viewModelFactory = sessionGraph.metroViewModelFactory, + sessionGraph = sessionGraph, + ) + ) + } catch (failure: SessionGraphUnavailableException) { + val reason = when (failure.lifecycle) { + SessionGraphStoreViewModel.Lifecycle.INVALIDATING -> + WireRouteScope.Unavailable.Reason.SESSION_GRAPH_INVALIDATING + + SessionGraphStoreViewModel.Lifecycle.REMOVED -> + WireRouteScope.Unavailable.Reason.SESSION_GRAPH_REMOVED + + SessionGraphStoreViewModel.Lifecycle.ACTIVE -> error( + "Session graph failed with an active lifecycle" + ) + } + MetroWireEntryGraphResolution.Unavailable(WireRouteScope.Unavailable(reason)) + } + } + + is WireRouteScope.Unavailable -> MetroWireEntryGraphResolution.Unavailable(routeScope) + } +} + +/** + * App-owned bridge between generic Navigation 3 entries and Metro. + * + * Keeping this in `app` preserves the dependency direction: `core:navigation` knows only about + * [WireEntryEnvironment], while the application decides which concrete Metro graph owns a route. + */ +internal class MetroWireEntryEnvironment( + private val graphResolver: MetroWireEntryGraphResolver, + private val logoutAction: (wipeData: Boolean) -> Unit, +) : WireEntryEnvironment { + + constructor( + appGraph: WireApplicationGraph, + authenticationGraph: AppAuthenticationViewModelGraph, + sessionGraphStore: SessionGraphStoreViewModel, + logoutAction: (wipeData: Boolean) -> Unit, + ) : this( + graphResolver = MetroWireEntryGraphResolver( + appGraph = appGraph, + authenticationGraph = authenticationGraph, + sessionGraphStore = sessionGraphStore, + ), + logoutAction = logoutAction, + ) + + @Composable + override fun Provide( + route: WireRoute, + content: @Composable () -> Unit, + ) { + when (val resolution = graphResolver.resolve(route)) { + is MetroWireEntryGraphResolution.Available -> { + val entryGraph = resolution.entryGraph + WireNavigationDiagnostics.metro( + route = route, + scope = if (entryGraph.sessionGraph == null) "authentication" else "session", + outcome = "available", + ) + CompositionLocalProvider( + LocalMetroViewModelFactory provides entryGraph.viewModelFactory, + LocalAuthenticationCancelUserId provides + (route as? SessionRoute)?.sessionId?.toKaliumUserId(), + LocalWireSessionImageLoader provides entryGraph.sessionGraph?.wireSessionImageLoader, + LocalSelfUserProfileLogoutAction provides logoutAction, + ) { + content() + } + } + + is MetroWireEntryGraphResolution.Unavailable -> { + WireNavigationDiagnostics.metro( + route = route, + scope = "unavailable", + outcome = resolution.routeScope.reason.toString(), + ) + when (resolution.routeScope.reason) { + WireRouteScope.Unavailable.Reason.SESSION_GRAPH_INVALIDATING, + WireRouteScope.Unavailable.Reason.SESSION_GRAPH_REMOVED -> Unit + WireRouteScope.Unavailable.Reason.ROUTE_HAS_NO_SCOPE, + WireRouteScope.Unavailable.Reason.ROUTE_HAS_MULTIPLE_SCOPES -> error( + "No Metro graph is available for route ${route::class.qualifiedName}: " + + resolution.routeScope.reason + ) + } + } + } + } +} + +internal fun WireSessionId.toKaliumUserId(): UserId = UserId(value = value, domain = domain) diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/Navigation3RouteObservation.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/Navigation3RouteObservation.kt new file mode 100644 index 00000000000..b4eaf720066 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/Navigation3RouteObservation.kt @@ -0,0 +1,41 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.util.CurrentScreenManager +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Navigation 3 replacement for NavController destination listeners. + * + * Entry identity is part of each route, so navigating to the same destination with new arguments + * still emits a screen transition while recomposition of the same entry does not. + */ +@Composable +internal fun ObserveNavigation3Routes( + runtime: WireNavigation3Runtime, + currentScreenManager: CurrentScreenManager, + onRouteChanged: () -> Unit, +) { + LaunchedEffect(runtime, currentScreenManager) { + snapshotFlow { runtime.navigator.currentRoute } + .distinctUntilChanged() + .collect { route -> + route ?: return@collect + onRouteChanged() + currentScreenManager.onRouteChanged(route) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModel.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModel.kt new file mode 100644 index 00000000000..826c96c7bf6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModel.kt @@ -0,0 +1,333 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.wire.android.appLogger +import com.wire.android.di.metro.AppSessionViewModelGraph +import com.wire.android.di.metro.WireApplicationGraph +import com.wire.android.di.metro.createSessionViewModelGraph +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.auth.LogoutCallback +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException + +/** + * The single creation boundary for the Activity-retained session graph registry. + * + * Feature ViewModels are created through the Metro gateway. This infrastructure ViewModel owns + * Metro session graphs themselves, so it must be created by the framework before a session graph + * exists and is deliberately isolated behind this runtime-specific gateway. + */ +@Composable +internal fun wireSessionGraphStoreViewModel( + appGraph: WireApplicationGraph, + owner: ViewModelStoreOwner, +): SessionGraphStoreViewModel = viewModel( + viewModelStoreOwner = owner, + factory = viewModelFactory { + initializer { SessionGraphStoreViewModel(appGraph) } + }, +) + +internal class SessionGraphStoreViewModel( + private val createSessionGraph: (UserId) -> AppSessionViewModelGraph, + private val registerLogoutCallback: ((LogoutCallback) -> Unit)? = null, + private val unregisterLogoutCallback: ((LogoutCallback) -> Unit)? = null, +) : ViewModel() { + + /* + * Host lifecycle commands are ordered on the main thread: invalidate, remove all consumers, + * dispose, then make a newly authenticated generation available. Synchronization here closes + * graph-creation/logout races; it does not make removal a revocable lease for callers that + * already obtained a graph. + */ + + internal enum class Lifecycle { + ACTIVE, + INVALIDATING, + REMOVED, + } + + private sealed interface Entry { + data class Creating(val result: CompletableFuture) : Entry + data class Active(val graph: AppSessionViewModelGraph) : Entry + data class Invalidating(val graph: AppSessionViewModelGraph?) : Entry + data class Releasing(val result: CompletableFuture) : Entry + data object Removed : Entry + } + + constructor(appGraph: WireApplicationGraph) : this( + createSessionGraph = appGraph::createSessionViewModelGraph, + registerLogoutCallback = appGraph.coreLogic.getGlobalScope().logoutCallbackManager::register, + unregisterLogoutCallback = appGraph.coreLogic.getGlobalScope().logoutCallbackManager::unregister, + ) + + private val entries = mutableMapOf() + private var cleared = false + private val logoutCallback = object : LogoutCallback { + override suspend fun invoke(userId: UserId, reason: LogoutReason) { + // The callback can race the Activity's navigation effects. It may block stale graph + // resolution immediately, but final disposal belongs to the ordered host coordinator: + // entry effects -> ViewModelStore -> Metro graph. + markInvalidating(userId) + } + } + + init { + registerLogoutCallback?.invoke(logoutCallback) + } + + @Suppress("CyclomaticComplexMethod", "ReturnCount", "ThrowsCount", "TooGenericExceptionCaught") + fun graphFor(userId: UserId): AppSessionViewModelGraph { + var createsGraph = false + val result = synchronized(this) { + if (cleared) throw unavailable(Lifecycle.REMOVED) + when (val entry = entries[userId]) { + is Entry.Active -> return entry.graph + is Entry.Invalidating, + is Entry.Releasing, + Entry.Removed -> throw unavailable(lifecycleFor(entry)) + + is Entry.Creating -> entry.result + null -> CompletableFuture().also { + createsGraph = true + entries[userId] = Entry.Creating(it) + } + } + } + + if (!createsGraph) return result.awaitGraph() + + val graph = try { + appLogger.i("WireActivity creating lifecycle-retained session graph for $REDACTED_SESSION_LABEL") + createSessionGraph(userId) + } catch (failure: Throwable) { + synchronized(this) { + if ((entries[userId] as? Entry.Creating)?.result === result) { + entries.remove(userId) + } + } + result.completeExceptionally(failure) + throw failure + } + + val unavailableLifecycle = synchronized(this) { + when (val entry = entries[userId]) { + is Entry.Creating -> if (entry.result === result) { + entries[userId] = Entry.Active(graph) + result.complete(graph) + null + } else { + Lifecycle.REMOVED + } + + is Entry.Releasing -> { + if (entry.result === result) entries.remove(userId) + Lifecycle.REMOVED + } + + is Entry.Invalidating -> Lifecycle.INVALIDATING + Entry.Removed -> Lifecycle.REMOVED + is Entry.Active, + null -> Lifecycle.REMOVED + } + } + if (unavailableLifecycle == null) return graph + + val failure = unavailable(unavailableLifecycle) + result.completeExceptionally(failure) + dispose(graph, "discarded after lifecycle changed") + throw failure + } + + /** Marks a session unavailable before its remaining navigation entries are torn down. */ + internal fun markInvalidating(userId: UserId) { + synchronized(this) { + if (cleared) return + when (val entry = entries[userId]) { + is Entry.Active -> entries[userId] = Entry.Invalidating(entry.graph) + is Entry.Creating -> { + entries[userId] = Entry.Invalidating(graph = null) + entry.result.completeExceptionally(unavailable(Lifecycle.INVALIDATING)) + } + + null -> entries[userId] = Entry.Invalidating(graph = null) + is Entry.Releasing -> entries[userId] = Entry.Invalidating(graph = null) + is Entry.Invalidating, + Entry.Removed -> Unit + } + } + appLogger.i("WireActivity marked session graph invalidating for $REDACTED_SESSION_LABEL") + } + + /** Completes removal and keeps a tombstone that rejects stale route composition. */ + internal fun markRemoved(userId: UserId) { + val graphToDispose = synchronized(this) { + if (cleared) return + when (val entry = entries[userId]) { + is Entry.Active -> entry.graph + is Entry.Invalidating -> entry.graph + is Entry.Creating -> { + entry.result.completeExceptionally(unavailable(Lifecycle.REMOVED)) + null + } + + is Entry.Releasing, + Entry.Removed, + null -> null + } + .also { entries[userId] = Entry.Removed } + } + graphToDispose?.let { dispose(it, "marked removed") } + } + + /** Starts a new graph generation after the session has been independently confirmed valid. */ + internal fun markAvailable(userId: UserId) { + val reopened = synchronized(this) { + if (cleared) return + when (entries[userId]) { + Entry.Removed -> { + entries.remove(userId) + true + } + + is Entry.Invalidating -> error( + "Session graph must be REMOVED before it can become available again" + ) + + is Entry.Active, + is Entry.Creating, + is Entry.Releasing, + null -> false + } + } + if (reopened) { + appLogger.i("WireActivity reopened session graph lifecycle for $REDACTED_SESSION_LABEL") + } + } + + /** + * Releases an Activity-local graph without invalidating the underlying Kalium session. + * + * Call activities use this when a reused singleTop/singleInstance Activity receives an intent + * for another valid account. Unlike [markRemoved], this deliberately leaves no tombstone. + */ + internal fun release(userId: UserId) { + val graphToDispose = synchronized(this) { + if (cleared) return + when (val entry = entries[userId]) { + is Entry.Active -> { + entries.remove(userId) + entry.graph + } + + is Entry.Creating -> { + entries[userId] = Entry.Releasing(entry.result) + entry.result.completeExceptionally(unavailable(Lifecycle.REMOVED)) + null + } + + is Entry.Invalidating -> { + entries[userId] = Entry.Removed + entry.graph + } + + is Entry.Releasing, + Entry.Removed, + null -> null + } + } + graphToDispose?.let { dispose(it, "released") } + } + + internal fun lifecycle(userId: UserId): Lifecycle? = synchronized(this) { + entries[userId]?.let(::lifecycleFor) + } + + @Suppress("TooGenericExceptionCaught") + private fun dispose(graph: AppSessionViewModelGraph, reason: String) { + appLogger.i("WireActivity $reason lifecycle-retained session graph for $REDACTED_SESSION_LABEL") + try { + graph.wireSessionImageLoader.shutdown() + } catch (failure: Exception) { + appLogger.e("WireActivity failed to dispose a lifecycle-retained session graph", failure) + } + } + + private fun lifecycleFor(entry: Entry): Lifecycle = when (entry) { + is Entry.Creating, + is Entry.Active -> Lifecycle.ACTIVE + is Entry.Invalidating -> Lifecycle.INVALIDATING + is Entry.Releasing, + Entry.Removed -> Lifecycle.REMOVED + } + + override fun onCleared() { + unregisterLogoutCallback?.invoke(logoutCallback) + val graphs = synchronized(this) { + cleared = true + val retainedGraphs = entries.mapNotNull { (_, entry) -> + when (entry) { + is Entry.Active -> entry.graph + is Entry.Invalidating -> entry.graph + is Entry.Creating -> { + entry.result.completeExceptionally(unavailable(Lifecycle.REMOVED)) + null + } + + is Entry.Releasing -> { + entry.result.completeExceptionally(unavailable(Lifecycle.REMOVED)) + null + } + + Entry.Removed -> null + } + } + entries.clear() + retainedGraphs + } + graphs.forEach { graph -> dispose(graph, "cleared") } + } + + private fun unavailable(lifecycle: Lifecycle) = + SessionGraphUnavailableException(REDACTED_SESSION_LABEL, lifecycle) + + private companion object { + const val REDACTED_SESSION_LABEL = "session:" + } +} + +internal class SessionGraphUnavailableException( + sessionLabel: String, + val lifecycle: SessionGraphStoreViewModel.Lifecycle, +) : IllegalStateException("Session graph for $sessionLabel is $lifecycle") + +private fun CompletableFuture.awaitGraph(): AppSessionViewModelGraph = + try { + join() + } catch (failure: CompletionException) { + throw (failure.cause ?: failure) + } diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityGraphContext.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityGraphContext.kt new file mode 100644 index 00000000000..c69f9a4f570 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityGraphContext.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import com.wire.android.di.metro.AppSessionViewModelGraph +import com.wire.android.di.metro.MetroViewModelGraph +import com.wire.android.model.LocalWireSessionImageLoader +import com.wire.android.ui.authentication.LocalAuthenticationCancelUserId +import com.wire.android.ui.userprofile.self.LocalSelfUserProfileLogoutAction +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory + +internal data class WireActivityGraphContext( + val graph: MetroViewModelGraph, + val viewModelFactory: MetroViewModelFactory, + val sessionId: WireSessionId?, + val sessionGraph: AppSessionViewModelGraph?, +) + +/** + * Resolves Activity-owned chrome and dialog ViewModels from the same typed route environment as + * its Navigation 3 entry. This is the production replacement for generated-route classification + * and argument Bundle inspection. + */ +@Composable +internal fun rememberWireNavigation3ActivityGraphContext( + route: WireRoute?, + graphResolver: MetroWireEntryGraphResolver, + isUserUiBlocked: Boolean, +): WireActivityGraphContext? { + if (route == null || isUserUiBlocked) return null + + return remember(route, graphResolver) { + when (val resolution = graphResolver.resolve(route)) { + is MetroWireEntryGraphResolution.Available -> { + val entryGraph = resolution.entryGraph + WireActivityGraphContext( + graph = entryGraph.graph, + viewModelFactory = entryGraph.viewModelFactory, + sessionId = (route as? SessionRoute)?.sessionId, + sessionGraph = entryGraph.sessionGraph, + ) + } + + is MetroWireEntryGraphResolution.Unavailable -> null + } + } +} + +@Composable +internal fun WireActivityGraphContext.ProvideViewModelGraph( + logoutAction: (wipeData: Boolean) -> Unit, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalMetroViewModelFactory provides viewModelFactory, + LocalAuthenticationCancelUserId provides sessionId?.toKaliumUserId(), + LocalWireSessionImageLoader provides sessionGraph?.wireSessionImageLoader, + LocalSelfUserProfileLogoutAction provides logoutAction, + content = content, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinator.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinator.kt new file mode 100644 index 00000000000..83621fa6bda --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinator.kt @@ -0,0 +1,145 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Owns the Activity-level intent queue and the Android saved-state protocol used to prevent + * a deep link from being handled again after Activity recreation. + * + * The queue is intentionally unlimited: intents may arrive before the Compose navigation host + * starts collecting, and every intent must be delivered exactly once in arrival order. + */ +internal class WireActivityIntentCoordinator { + + private val pendingRequests = Channel(Channel.UNLIMITED) + + val requests: Flow = pendingRequests.receiveAsFlow() + + fun enqueue(intent: Intent?, savedInstanceState: Bundle? = null) { + check(pendingRequests.trySend(WireActivityIntentRequest(intent, savedInstanceState)).isSuccess) { + "WireActivity intent queue is unexpectedly closed" + } + } + + fun saveInstanceState(outState: Bundle, currentIntent: Intent?) { + outState.putBoolean(HANDLED_DEEP_LINK_KEY, true) + outState.putParcelable(ORIGINAL_INTENT_KEY, currentIntent) + } + + fun restoreActivityIntent(savedInstanceState: Bundle): Intent? = + savedInstanceState.originalIntent() + + suspend fun handle( + request: WireActivityIntentRequest, + isEmptyWelcomeStartDestination: () -> Boolean, + handleNonDeepLinkIntent: suspend (Intent?) -> Boolean, + handleDeepLink: suspend (Intent) -> Unit, + ): WireActivityIntentEffect { + val intent = request.intent + val handling = decideWireActivityIntentHandling( + WireActivityIntentEvidence( + isMissing = intent == null, + isLauncherIntent = intent?.action == Intent.ACTION_MAIN, + isLaunchedFromHistory = intent?.let { + it.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + } == true, + isRestoredOriginalIntent = request.savedInstanceState.originalIntent() == intent, + wasDeepLinkHandled = intent?.getBooleanExtra(HANDLED_DEEP_LINK_KEY, false) == true, + ) + ) + val wasHandledAsNonDeepLink = handleNonDeepLinkIntent(intent) + + if (handling == WireActivityIntentHandling.NON_DEEP_LINK_THEN_DEEP_LINK && + !wasHandledAsNonDeepLink && + intent != null + ) { + handleDeepLink(intent) + intent.putExtra(HANDLED_DEEP_LINK_KEY, true) + } + + return if ( + handling == WireActivityIntentHandling.NON_DEEP_LINK_ONLY && + !wasHandledAsNonDeepLink && + isEmptyWelcomeStartDestination() + ) { + WireActivityIntentEffect.OPEN_LOGIN + } else { + WireActivityIntentEffect.NONE + } + } + + private fun Bundle?.originalIntent(): Intent? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + this?.getParcelable(ORIGINAL_INTENT_KEY) + } else { + this?.getParcelable(ORIGINAL_INTENT_KEY, Intent::class.java) + } + + private companion object { + const val HANDLED_DEEP_LINK_KEY = "deeplink_handled_flag_key" + const val ORIGINAL_INTENT_KEY = "original_saved_intent" + } +} + +internal data class WireActivityIntentRequest( + val intent: Intent?, + val savedInstanceState: Bundle?, +) + +internal enum class WireActivityIntentEffect { + NONE, + OPEN_LOGIN, +} + +internal enum class WireActivityIntentHandling { + NON_DEEP_LINK_ONLY, + NON_DEEP_LINK_THEN_DEEP_LINK, +} + +internal data class WireActivityIntentEvidence( + val isMissing: Boolean = false, + val isLauncherIntent: Boolean = false, + val isLaunchedFromHistory: Boolean = false, + val isRestoredOriginalIntent: Boolean = false, + val wasDeepLinkHandled: Boolean = false, +) + +internal fun decideWireActivityIntentHandling( + evidence: WireActivityIntentEvidence, +): WireActivityIntentHandling = + if (evidence.shouldSkipDeepLinkHandling) { + WireActivityIntentHandling.NON_DEEP_LINK_ONLY + } else { + WireActivityIntentHandling.NON_DEEP_LINK_THEN_DEEP_LINK + } + +private val WireActivityIntentEvidence.shouldSkipDeepLinkHandling: Boolean + get() = isMissing || + isLauncherIntent || + isLaunchedFromHistory || + isRestoredOriginalIntent || + wasDeepLinkHandled diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffects.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffects.kt new file mode 100644 index 00000000000..afedfc82628 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffects.kt @@ -0,0 +1,196 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.net.Uri +import com.wire.android.R +import com.wire.android.navigation.routes.auth.AuthenticationTeamAccountCreationRequest +import com.wire.android.navigation.routes.auth.NewLoginPasswordRoute +import com.wire.android.ui.WireActivity +import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.EmailComposer +import com.wire.android.util.SupportPage +import com.wire.android.util.SupportUrlResolver +import com.wire.android.util.getDeviceIdString +import com.wire.android.util.getGitBuildId +import com.wire.android.util.sha256 +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand + +/** + * Resolved Android effects for semantic Navigation 3 external destinations. + * + * URL selection is kept separate from execution so the mapping can be tested without an Activity + * and no generated legacy route type needs to cross the Navigation 3 boundary. + */ +internal sealed interface WireNavigation3PlatformEffect { + data class CustomTab(val url: String) : WireNavigation3PlatformEffect + data object GiveFeedbackEmail : WireNavigation3PlatformEffect + data object None : WireNavigation3PlatformEffect +} + +internal data class WireNavigation3ExternalLinks( + val support: String, + val teamManagement: String, + val teamPlan: String, + val termsOfUse: String, + val wireWebsite: String, + val privacyPolicy: String, + val reportMisuse: String, + val welcomeAndroid: String, + val androidReleaseNotes: String, +) + +internal fun resolveWireNavigation3PlatformEffect( + destination: WireNavigation3ExternalIntent, + links: WireNavigation3ExternalLinks, +): WireNavigation3PlatformEffect = when (destination) { + WireNavigation3ExternalIntent.SUPPORT -> + WireNavigation3PlatformEffect.CustomTab(links.support) + WireNavigation3ExternalIntent.TEAM_MANAGEMENT -> + links.teamManagement.toCustomTabOrNone() + WireNavigation3ExternalIntent.TEAM_PLAN -> + WireNavigation3PlatformEffect.CustomTab(links.teamPlan) + WireNavigation3ExternalIntent.TERMS_OF_USE -> + WireNavigation3PlatformEffect.CustomTab(links.termsOfUse) + WireNavigation3ExternalIntent.WIRE_WEBSITE -> + WireNavigation3PlatformEffect.CustomTab(links.wireWebsite) + WireNavigation3ExternalIntent.PRIVACY_POLICY -> + WireNavigation3PlatformEffect.CustomTab(links.privacyPolicy) + WireNavigation3ExternalIntent.REPORT_MISUSE -> + WireNavigation3PlatformEffect.CustomTab(links.reportMisuse) + WireNavigation3ExternalIntent.GIVE_FEEDBACK -> + WireNavigation3PlatformEffect.GiveFeedbackEmail + WireNavigation3ExternalIntent.WELCOME_ANDROID -> + WireNavigation3PlatformEffect.CustomTab(links.welcomeAndroid) + WireNavigation3ExternalIntent.ANDROID_RELEASE_NOTES -> + WireNavigation3PlatformEffect.CustomTab(links.androidReleaseNotes) +} + +private fun String.toCustomTabOrNone(): WireNavigation3PlatformEffect = + takeIf(String::isNotBlank) + ?.let(WireNavigation3PlatformEffect::CustomTab) + ?: WireNavigation3PlatformEffect.None + +internal fun wireNavigation3ExternalLinks( + resources: Resources, + teamManagementUrl: String?, +): WireNavigation3ExternalLinks = WireNavigation3ExternalLinks( + support = SupportUrlResolver.resolve(resources, SupportPage.SUPPORT), + teamManagement = teamManagementUrl.orEmpty(), + teamPlan = resources.getString(R.string.url_wire_plans), + termsOfUse = resources.getString(R.string.url_terms_of_use_legal), + wireWebsite = resources.getString(R.string.url_wire_website), + privacyPolicy = resources.getString(R.string.url_privacy_policy), + reportMisuse = SupportUrlResolver.resolve(resources, SupportPage.REPORT_MISUSE), + welcomeAndroid = SupportUrlResolver.resolve(resources, SupportPage.WELCOME_ANDROID), + androidReleaseNotes = resources.getString(R.string.url_android_release_notes), +) + +/** + * Payload handed to WireActivity's StartActivityForResult launcher. + * + * Returning from the custom tab must update the existing login-flow entry rather than push a + * duplicate. WireActivity remains the owner of the runtime mutation. + */ +internal data class WireNavigation3TeamAccountWebLaunch( + val intent: Intent, + val returnRoute: NewLoginPasswordRoute, +) { + fun returnCommand(): WireNavigationCommand = WireNavigationCommand( + destination = returnRoute, + backStackMode = WireBackStackMode.UPDATE_EXISTING, + ) +} + +internal fun buildWireNavigation3TeamAccountWebLaunch( + context: Context, + request: AuthenticationTeamAccountCreationRequest, +): WireNavigation3TeamAccountWebLaunch { + val customTabIntent = CustomTabsHelper.buildCustomTabIntent(context).intent.apply { + data = Uri.parse(request.url) + } + return WireNavigation3TeamAccountWebLaunch( + intent = customTabIntent, + returnRoute = request.returnRoute, + ) +} + +internal fun buildWireNavigation3FeedbackIntent(context: Context): Intent { + val emailIntent = Intent(Intent.ACTION_SEND).apply { + putExtra( + Intent.EXTRA_EMAIL, + arrayOf("wire-newandroid-feedback@wearezeta.zendesk.com") + ) + putExtra(Intent.EXTRA_SUBJECT, "Feedback - Wire Beta") + putExtra( + Intent.EXTRA_TEXT, + EmailComposer.giveFeedbackEmailTemplate( + context.getDeviceIdString()?.sha256(), + context.getGitBuildId(), + ) + ) + selector = Intent(Intent.ACTION_SENDTO).setData(Uri.parse("mailto:")) + } + return Intent.createChooser( + emailIntent, + context.getString(R.string.send_feedback_choose_email), + ) +} + +internal fun buildWireNavigation3RestartIntent(context: Context): Intent = + Intent(context, WireActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + +/** + * Android-bound executor whose method references fit the external members of + * [WireNavigation3ActivityCallbacks]. + */ +internal class WireNavigation3ActivityEffects( + private val activity: Activity, + private val teamManagementUrl: () -> String? = { null }, +) { + fun openUrl(url: String) { + CustomTabsHelper.launchUrl(activity, url) + } + + fun openExternal(destination: WireNavigation3ExternalIntent) { + when ( + val effect = resolveWireNavigation3PlatformEffect( + destination = destination, + links = wireNavigation3ExternalLinks( + resources = activity.resources, + teamManagementUrl = teamManagementUrl(), + ), + ) + ) { + is WireNavigation3PlatformEffect.CustomTab -> openUrl(effect.url) + WireNavigation3PlatformEffect.GiveFeedbackEmail -> + activity.startActivity(buildWireNavigation3FeedbackIntent(activity)) + WireNavigation3PlatformEffect.None -> Unit + } + } + + fun teamAccountWebLaunch( + request: AuthenticationTeamAccountCreationRequest, + ): WireNavigation3TeamAccountWebLaunch = + buildWireNavigation3TeamAccountWebLaunch(activity, request) + + fun restartAfterLogout() { + activity.startActivity(buildWireNavigation3RestartIntent(activity)) + activity.finish() + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt new file mode 100644 index 00000000000..52dfda96ce3 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt @@ -0,0 +1,49 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.style.BackgroundType +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireRoute + +/** + * Typed Activity policy shared by the Navigation 3 host and unit tests. + * + * It replaces generated destination style lookup and legacy base-route classification. Session + * ownership is derived exclusively from route marker interfaces, so restored routes and deep + * links follow the same policy as ordinary navigation. + */ +internal object WireNavigation3ActivityPolicy { + + fun backgroundType(route: WireRoute?): BackgroundType = + if (route is AuthenticationScreenRoute) BackgroundType.Auth else BackgroundType.Default + + fun sessionSnapshot( + route: WireRoute?, + hasCurrentSession: Boolean, + isUserUiBlocked: Boolean, + isSessionTransitionInProgress: Boolean, + isSelfLogoutTransition: Boolean, + ): WireSessionNavigationSnapshot = + WireSessionNavigationSnapshot( + hasCurrentSession = hasCurrentSession, + hasCurrentRoute = route != null, + isEmptyWelcomeRoute = route is NewWelcomeEmptyStartRoute, + isAuthenticationRoute = route is AuthenticationScreenRoute, + isSessionBackedAuthenticationRoute = + route is AuthenticationScreenRoute && route is SessionRoute, + isUserUiBlocked = isUserUiBlocked, + isSessionTransitionInProgress = isSessionTransitionInProgress, + isSelfLogoutTransition = isSelfLogoutTransition, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt new file mode 100644 index 00000000000..87b0168a02b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt @@ -0,0 +1,168 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.feature.meetings.ui.create.MeetingsNavigation3Actions +import com.wire.android.feature.meetings.ui.create.MeetingsNavigation3Contribution +import com.wire.android.feature.sketch.navigation.SketchNavigation3Contribution +import com.wire.android.feature.cells.navigation.CellsNavigation3Contribution +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Contribution +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.media.MediaNavigation3Actions +import com.wire.android.navigation.routes.media.MediaNavigation3Contribution +import com.wire.android.navigation.routes.utility.InitialSyncNavigation3Actions +import com.wire.android.navigation.routes.utility.UtilityNavigation3Contribution +import com.wire.android.ui.home.HomeNavigation3Actions +import com.wire.android.ui.home.HomeNavigation3Contribution +import com.wire.android.ui.home.HomeTopLevelNavigation3Actions +import com.wire.android.ui.home.appLock.AppLockNavigation3Actions +import com.wire.android.ui.home.appLock.AppLockNavigation3Contribution +import com.wire.android.ui.home.conversations.ConversationAuxNavigation3Actions +import com.wire.android.ui.home.conversations.ConversationAuxNavigation3Contribution +import com.wire.android.ui.home.conversations.ConversationNavigation3Actions +import com.wire.android.ui.home.conversations.ConversationNavigation3Contribution +import com.wire.android.ui.home.conversations.details.ConversationDetailsNavigation3Actions +import com.wire.android.ui.home.conversations.details.ConversationDetailsNavigation3Contribution +import com.wire.android.ui.home.newconversation.NewConversationNavigation3Actions +import com.wire.android.ui.home.newconversation.newConversationNavigation3Entries +import com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryNavigation3Pilot +import com.wire.android.ui.home.settings.SettingsNavigation3Actions +import com.wire.android.ui.home.settings.SettingsNavigation3Contribution +import com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions +import com.wire.android.ui.settings.devices.DeviceE2EINavigation3Contribution +import com.wire.android.ui.userprofile.UserProfileNavigation3Actions +import com.wire.android.ui.userprofile.UserProfileNavigation3Contribution +import com.wire.android.ui.userprofile.teammigration.TeamMigrationNavigation3Actions +import com.wire.android.ui.userprofile.teammigration.TeamMigrationNavigation3Contribution + +/** + * The one host boundary required by every currently migrated Navigation 3 contribution. + * + * [HomeNavigation3Actions] deliberately exposes its existing nested callback bundles for the + * Conversations and Cells top-level content. Its top-level and settings interfaces, however, are + * this same host contract, avoiding a second action implementation with subtly different state. + */ +internal interface WireNavigation3CompositeActions : + AuthenticationNavigation3Actions, + HomeNavigation3Actions, + HomeTopLevelNavigation3Actions, + NewConversationNavigation3Actions, + SettingsNavigation3Actions, + DeviceE2EINavigation3Actions, + UserProfileNavigation3Actions, + TeamMigrationNavigation3Actions, + ConversationNavigation3Actions, + ConversationAuxNavigation3Actions, + ConversationDetailsNavigation3Actions, + MediaNavigation3Actions, + InitialSyncNavigation3Actions, + AppLockNavigation3Actions, + MeetingsNavigation3Actions { + + override val topLevel: HomeTopLevelNavigation3Actions + get() = this + + override val settings: SettingsNavigation3Actions + get() = this + + fun exitCellsFlow() +} + +/** + * Complete production-host input assembled from the independently owned feature contributions. + */ +internal data class WireNavigation3ContributionCatalog( + val resultTypes: List>, + val entryProviderInstallers: List, +) + +/** + * App-owned composition root for the migrated Navigation 3 surface. + * + * Ordering is intentional and stable: roots precede their feature details, then utility and + * app-lock overlays finish the registry. An entry type must be owned by exactly one contribution. + */ +internal object WireNavigation3Contributions { + const val EXPECTED_ROUTE_REGISTRATION_COUNT: Int = 106 + const val EXPECTED_INSTALLER_COUNT: Int = 19 + + fun create( + runtime: WireNavigation3Runtime, + actions: WireNavigation3CompositeActions, + authenticationRouter: AuthenticationNavigation3Router, + ): WireNavigation3ContributionCatalog { + return WireNavigation3ContributionCatalog( + resultTypes = resultTypes(), + entryProviderInstallers = entryProviderInstallers(runtime, actions, authenticationRouter), + ) + } + + fun resultTypes(): List> = + buildList { + addAll(AuthenticationNavigation3Contribution.resultTypes) + addAll(ChannelHistoryNavigation3Pilot.resultTypes) + addAll(SettingsNavigation3Contribution.resultTypes) + addAll(DeviceE2EINavigation3Contribution.resultTypes) + addAll(UserProfileNavigation3Contribution.resultTypes) + addAll(ConversationNavigation3Contribution.resultTypes) + addAll(ConversationAuxNavigation3Contribution.resultTypes) + addAll(ConversationDetailsNavigation3Contribution.resultTypes) + addAll(MediaNavigation3Contribution.resultTypes) + addAll(UtilityNavigation3Contribution.resultTypes) + addAll(AppLockNavigation3Contribution.resultTypes) + addAll(CellsNavigation3Contribution.resultTypes) + addAll(SketchNavigation3Contribution.resultTypes) + }.requireUniqueResultContractIds() + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: WireNavigation3CompositeActions, + authenticationRouter: AuthenticationNavigation3Router = AuthenticationNavigation3Router(runtime), + ): List = + buildList { + // Authentication includes the create-account installer. + addAll(AuthenticationNavigation3Contribution.entryProviderInstallers(actions, authenticationRouter)) + addAll(HomeNavigation3Contribution.entryProviderInstallers(runtime, actions)) + add(newConversationNavigation3Entries(runtime, actions)) + addAll(ChannelHistoryNavigation3Pilot.entryProviderInstallers(runtime)) + addAll(SettingsNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(DeviceE2EINavigation3Contribution.entryProviderInstallers(runtime, actions, authenticationRouter)) + addAll(UserProfileNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(TeamMigrationNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(ConversationNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(ConversationAuxNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(ConversationDetailsNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(MediaNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(UtilityNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(AppLockNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(CellsNavigation3Contribution.entryProviderInstallers(runtime, actions::exitCellsFlow)) + addAll(MeetingsNavigation3Contribution.entryProviderInstallers(runtime, actions)) + addAll(SketchNavigation3Contribution.entryProviderInstallers(runtime)) + }.also { installers -> + check(installers.size == EXPECTED_INSTALLER_COUNT) { + "Expected $EXPECTED_INSTALLER_COUNT Navigation 3 installers, found ${installers.size}" + } + } +} + +private fun List>.requireUniqueResultContractIds(): List> { + val duplicateIds = groupBy { it.contract.id.value } + .filterValues { it.size > 1 } + .keys + require(duplicateIds.isEmpty()) { + "Duplicate Navigation 3 result contract ids: ${duplicateIds.sorted()}" + } + return this +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt new file mode 100644 index 00000000000..0d8034b2084 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt @@ -0,0 +1,656 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +@file:Suppress("TooManyFunctions") + +package com.wire.android.navigation.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.wire.android.feature.cells.ui.AllFilesNavigationActions +import com.wire.android.feature.cells.navigation.AddRemoveTagsRoute +import com.wire.android.feature.cells.navigation.CellImageViewerRoute +import com.wire.android.feature.cells.navigation.CellsFilesArguments +import com.wire.android.feature.cells.navigation.CellsSearchType +import com.wire.android.feature.cells.navigation.ConversationFilesRoute +import com.wire.android.feature.cells.navigation.PublicLinkRoute +import com.wire.android.feature.cells.navigation.SearchRoute +import com.wire.android.feature.cells.navigation.VideoPlayerRoute +import com.wire.android.feature.meetings.ui.create.MeetingParticipantId +import com.wire.android.feature.meetings.ui.create.NewMeetingDetailsRoute +import com.wire.android.navigation.LoginTypeSelector +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.auth.AuthenticationLoginCompletion +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.navigation.routes.auth.AuthenticationTeamAccountCreationRequest +import com.wire.android.navigation.routes.auth.CreateAccountUsernameRoute +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.navigation.routes.media.ConversationMediaRoute +import com.wire.android.navigation.routes.media.MediaAssetDto +import com.wire.android.navigation.routes.media.MediaConversationId +import com.wire.android.navigation.routes.utility.DebugRoute +import com.wire.android.navigation.routes.utility.InitialSyncRoute +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.home.HomeExternalDestination +import com.wire.android.ui.home.HomeRequirement +import com.wire.android.ui.home.appLock.SetLockCodeRoute +import com.wire.android.ui.home.conversations.BrowseChannelsRoute +import com.wire.android.ui.home.conversations.ConversationAuxId +import com.wire.android.ui.home.conversations.ConversationCompletionResult +import com.wire.android.ui.home.conversations.ConversationPendingAsset +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.home.conversations.ConversationRouteId +import com.wire.android.ui.home.conversations.DebugConversationRoute +import com.wire.android.ui.home.conversations.PromoteAdminRoute +import com.wire.android.ui.home.conversations.SearchConversationMessagesRoute +import com.wire.android.ui.home.conversations.AddMembersSearchRoute +import com.wire.android.ui.home.conversations.toNavigation3 +import com.wire.android.ui.home.conversations.details.ConversationDetailsId +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.newconversation.NewConversationSearchPeopleRoute +import com.wire.android.ui.home.settings.AboutThisAppRoute +import com.wire.android.ui.home.settings.AppSettingsRoute +import com.wire.android.ui.home.settings.MyAccountRoute +import com.wire.android.ui.home.settings.SettingsNavigation3Destination +import com.wire.android.ui.home.whatsnew.WhatsNewNavigation3Target +import com.wire.android.ui.settings.devices.DeviceDetailsRoute +import com.wire.android.ui.settings.devices.DeviceTargetUserId +import com.wire.android.ui.settings.devices.SelfDevicesRoute +import com.wire.android.ui.settings.devices.SessionSetupDestination +import com.wire.android.ui.authentication.devices.register.RegisterDeviceRoute +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.android.ui.userprofile.self.SelfUserProfileRoute +import com.wire.android.ui.userprofile.service.ServiceDetailsRoute +import com.wire.android.ui.userprofile.service.ServiceProfileTarget +import com.wire.android.ui.userprofile.teammigration.TeamMigrationTeamPlanRoute +import com.wire.android.feature.meetings.ui.create.NewMeetingType +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId + +/** + * Activity-owned effects which must not leak an Android Activity, Intent or generated destination + * into the Navigation 3 action implementation. + */ +@Suppress("LongParameterList") +internal data class WireNavigation3ActivityCallbacks( + val finish: () -> Unit, + val openUrl: (String) -> Unit, + val openIntent: (WireNavigation3ExternalIntent) -> Unit, + val openTeamAccountWebFlow: (AuthenticationTeamAccountCreationRequest) -> Unit, + val completeAppLock: () -> Unit, + val cancelAppLock: () -> Unit, + val hardLogout: () -> Unit, + val restartAfterLogout: () -> Unit, + val moveTaskToBackground: () -> Unit, +) + +internal enum class WireNavigation3ExternalIntent { + SUPPORT, + TEAM_MANAGEMENT, + TEAM_PLAN, + TERMS_OF_USE, + WIRE_WEBSITE, + PRIVACY_POLICY, + REPORT_MISUSE, + GIVE_FEEDBACK, + WELCOME_ANDROID, + ANDROID_RELEASE_NOTES, +} + +/** + * Production implementation shared by every app-owned Navigation 3 contribution. + * + * Route creation and back-stack mutation live here; Activity-only work is represented by the + * semantic callback ports above. + */ +@Suppress("LargeClass", "TooManyFunctions") +internal class WireNavigation3ProductionActions( + private val runtime: WireNavigation3Runtime, + private val activity: WireNavigation3ActivityCallbacks, + private val currentSessionId: () -> WireSessionId?, + private val loginTypeSelector: LoginTypeSelector, + private val authenticationRouter: AuthenticationNavigation3Router, +) : WireNavigation3CompositeActions { + + internal var pendingBackgroundAfterHomeSession by mutableStateOf(null) + private set + + override val cells: AllFilesNavigationActions = AllFilesNavigationActions( + openSearch = { + navigate(SearchRoute(requireSession(), screenType = CellsSearchType.DRIVE)) + }, + showPublicLink = { + navigate( + PublicLinkRoute( + sessionId = requireSession(), + assetId = it.assetId, + fileName = it.fileName, + publicLinkId = it.linkId, + isFolder = it.isFolder, + ) + ) + }, + showAddRemoveTags = { + navigate(AddRemoveTagsRoute(requireSession(), it.uuid, it.tags)) + }, + showImageViewer = { + navigate( + CellImageViewerRoute( + sessionId = requireSession(), + localPath = it.localPath, + contentUrl = it.contentUrl, + previewUrl = it.previewUrl, + contentHash = it.contentHash, + fileName = it.name, + ) + ) + }, + showVideoPlayer = { + navigate( + VideoPlayerRoute( + sessionId = requireSession(), + localPath = it.localPath, + contentUrl = it.contentUrl, + fileName = it.name, + ) + ) + }, + showAudioPlayer = { + navigate( + com.wire.android.feature.cells.navigation.AudioPlayerRoute( + sessionId = requireSession(), + localPath = it.localPath, + contentUrl = it.contentUrl, + fileName = it.name, + ) + ) + }, + ) + override val conversations: ConversationsNavigationActions = ConversationsNavigationActions( + openConversation = { openConversation(it.toProfileId()) }, + openUserProfile = { openUserProfile(it.value, it.domain) }, + startConversation = ::openNewConversation, + browseChannels = { navigate(BrowseChannelsRoute(requireSession())) }, + openConversationFolders = {}, + promoteAdmin = { args -> + navigate( + PromoteAdminRoute( + requireSession(), + args.conversationId.toAuxId(), + args.eligibleMembers.mapNotNull(::parseEligibleMember), + ) + ) + }, + openDebugMenu = { navigate(DebugConversationRoute(requireSession(), it.conversationId.toAuxId())) }, + ) + + override fun canUseNewLogin() = loginTypeSelector.canUseNewLogin() + override fun exitAuthentication() = activity.finish() + override fun openUrl(url: String) = activity.openUrl(url) + override fun openTeamAccountCreation(request: AuthenticationTeamAccountCreationRequest) = + activity.openTeamAccountWebFlow(request) + + override fun onRequirement(requirement: HomeRequirement) = + authenticationRouter.homeRequirement(requirement, currentSessionId()).let { Unit } + + override fun openNewConversation() = + navigate(NewConversationSearchPeopleRoute.start(requireSession())) + + override fun openSelfProfile() = navigate(SelfUserProfileRoute(requireSession())) + + override fun openExternal(destination: HomeExternalDestination) = + activity.openIntent( + when (destination) { + HomeExternalDestination.SUPPORT -> WireNavigation3ExternalIntent.SUPPORT + HomeExternalDestination.TEAM_MANAGEMENT -> WireNavigation3ExternalIntent.TEAM_MANAGEMENT + } + ) + + override fun openWhatsNew(target: WhatsNewNavigation3Target) = + when (target) { + WhatsNewNavigation3Target.Welcome -> + activity.openIntent(WireNavigation3ExternalIntent.WELCOME_ANDROID) + WhatsNewNavigation3Target.AllAndroidReleaseNotes -> + activity.openIntent(WireNavigation3ExternalIntent.ANDROID_RELEASE_NOTES) + is WhatsNewNavigation3Target.ExternalReleaseNote -> + activity.openUrl(target.url) + } + override fun openNewMeeting(type: NewMeetingType) = + navigate(NewMeetingDetailsRoute.start(requireSession(), type)) + + override fun exitFlow() = activity.finish() + override fun openUserProfile(userIdValue: String, userIdDomain: String) = + navigate(OtherUserProfileRoute(requireSession(), UserProfileQualifiedId(userIdValue, userIdDomain))) + + override fun openServiceDetails(serviceId: String, providerId: String, useNewAppsUi: Boolean) = + navigate( + ServiceDetailsRoute( + requireSession(), + conversationId = null, + target = serviceTarget(serviceId, providerId, useNewAppsUi), + ) + ) + + override fun completeFlowWithCreatedConversation( + conversationIdValue: String, + conversationIdDomain: String, + ) = navigate( + ConversationRoute(requireSession(), ConversationRouteId(conversationIdValue, conversationIdDomain)), + WireBackStackMode.REMOVE_CURRENT_NESTED_FLOW, + ) + + override fun discardFlowToHome() = navigate(HomeRoute(requireSession()), WireBackStackMode.CLEAR_WHOLE) + override fun openTeamPlan() = activity.openIntent(WireNavigation3ExternalIntent.TEAM_PLAN) + + override fun open(destination: SettingsNavigation3Destination) { + WireNavigation3ProductionCommandResolver.settings(destination, requireSession())?.let { + runtime.navigator.navigate(it) + } ?: activity.openIntent(destination.toExternalIntent()) + } + + override fun openAppLockSetup() = navigate(SetLockCodeRoute(requireSession())) + override fun openConversations() = navigate(HomeRoute(requireSession()), WireBackStackMode.CLEAR_WHOLE) + override fun exitSettings() = goBackOrFinish() + + override fun exitDeviceManagement() = goBackOrFinish() + override fun exitUserProfile() = goBackOrFinish() + override fun openAppSettings() = navigate(AppSettingsRoute(requireSession())) + override fun openAddAccount() { + authenticationRouter.navigate( + AuthenticationNavigationTransition.HOME_TO_ADD_ACCOUNT, + authenticationRoute(), + ) + } + override fun openTeamMigration(wasMigrationDotActive: Boolean) = + navigate(TeamMigrationTeamPlanRoute.start(requireSession(), wasMigrationDotActive)) + + override fun openAccountDetails() = navigate(MyAccountRoute(requireSession())) + override fun switchedToAnotherAccount() = + navigate(HomeRoute(requireSession()), WireBackStackMode.CLEAR_WHOLE) + + override fun noOtherAccountToSwitch() { + noOtherAccountNavigationCommand( + currentRoute = runtime.navigator.currentRoute, + useNewLogin = canUseNewLogin(), + )?.let { + authenticationRouter.navigate(AuthenticationNavigationTransition.ACCOUNT_SWITCH_TO_LOGIN, it) + } + } + + override fun openConversation(conversationId: UserProfileQualifiedId) = + navigate( + ConversationRoute(requireSession(), conversationId.toConversationId()), + WireBackStackMode.UPDATE_EXISTING, + ) + + override fun openDeviceDetails(userId: UserProfileQualifiedId, clientId: String) = + navigate( + DeviceDetailsRoute( + requireSession(), + DeviceTargetUserId(userId.value, userId.domain), + clientId, + ) + ) + + override fun searchConversationMessages(conversationId: UserProfileQualifiedId) = + navigate(SearchConversationMessagesRoute(requireSession(), conversationId.toAuxId())) + + override fun openConversationMedia(conversationId: UserProfileQualifiedId) = + navigate(ConversationMediaRoute(requireSession(), conversationId.toMediaId())) + + override fun openConversationDebugMenu(conversationId: UserProfileQualifiedId) = + navigate(DebugConversationRoute(requireSession(), conversationId.toAuxId())) + + override fun exitMigration() = goBackOrFinish() + override fun completeMigrationToHome() = + navigate(HomeRoute(requireSession()), WireBackStackMode.CLEAR_WHOLE) + + override fun exitConversation() = goBackOrFinish() + override fun completeConversation(result: ConversationCompletionResult) = goBackOrFinish() + override fun openConversationAtMessage(conversationId: ConversationAuxId, messageId: String) = + navigate( + ConversationRoute( + requireSession(), + conversationId.toConversationId(), + searchedMessageId = messageId, + ), + WireBackStackMode.UPDATE_EXISTING, + ) + + override fun openCellsSearch(conversationId: ConversationAuxId) = + navigate( + SearchRoute( + sessionId = requireSession(), + conversationId = QualifiedID(conversationId.value, conversationId.domain).toString(), + ) + ) + + override fun openUserProfile(userId: ConversationAuxId) = + navigate(OtherUserProfileRoute(requireSession(), userId.toProfileId())) + + override fun openService( + conversationId: ConversationAuxId, + serviceId: ConversationAuxId, + shouldUseNewAppsUi: Boolean, + ) = navigate( + ServiceDetailsRoute( + requireSession(), + conversationId.toProfileId(), + serviceTarget(serviceId.value, serviceId.domain, shouldUseNewAppsUi), + ) + ) + + override fun exitDetails() = activity.finish() + + override fun openParticipantProfile(participant: UIParticipant, conversationId: ConversationDetailsId) { + val destination = when { + participant.isSelf -> SelfUserProfileRoute(requireSession()) + participant.isService -> ServiceDetailsRoute( + requireSession(), + conversationId.toProfileId(), + participant.botService?.let { + ServiceProfileTarget.Bot(UserProfileQualifiedId(it.id, it.provider)) + } ?: ServiceProfileTarget.App(participant.id.toProfileId()), + ) + else -> OtherUserProfileRoute( + requireSession(), + participant.id.toProfileId(), + conversationId.toProfileId(), + ) + } + navigate(destination) + } + + override fun openAddMembers( + conversationId: ConversationDetailsId, + isConversationAppsEnabled: Boolean, + isSelfPartOfATeam: Boolean, + protocolInfo: Conversation.ProtocolInfo, + shouldUseNewAppsUi: Boolean, + ) = navigate( + AddMembersSearchRoute( + requireSession(), + conversationId.toAuxId(), + isConversationAppsEnabled, + isSelfPartOfATeam, + protocolInfo.toNavigation3(), + shouldUseNewAppsUi, + ) + ) + + override fun openSearchMessages( + conversationId: ConversationDetailsId, + isCellsConversation: Boolean, + groupName: String, + ) = navigate( + SearchConversationMessagesRoute( + requireSession(), + conversationId.toAuxId(), + groupName, + isCellsConversation, + ) + ) + + override fun openConversationMedia( + conversationId: ConversationDetailsId, + isCellsConversation: Boolean, + groupName: String, + ) = navigate( + if (isCellsConversation) { + ConversationFilesRoute( + sessionId = requireSession(), + args = CellsFilesArguments( + conversationId = QualifiedID(conversationId.value, conversationId.domain).toString(), + breadcrumbs = listOf(groupName), + ), + ) + } else { + ConversationMediaRoute(requireSession(), conversationId.toMediaId()) + } + ) + + override fun completeDetails( + action: com.wire.android.ui.home.conversations.details.GroupConversationActionType, + conversationName: String, + ) = goBackOrFinish() + + override fun openPromoteAdmin(conversationId: ConversationId, eligibleMembers: List) = + navigate( + PromoteAdminRoute( + requireSession(), + conversationId.toAuxId(), + eligibleMembers.map { it.toAuxId() }, + ) + ) + + override fun openConversationDebugMenu(conversationId: ConversationId) = + navigate(DebugConversationRoute(requireSession(), conversationId.toAuxId())) + + override fun finishShare() = activity.finish() + override fun openAuthentication() = + navigate(authenticationRoute(), WireBackStackMode.CLEAR_WHOLE) + + override fun openConversationFromShare( + conversationId: MediaConversationId, + assets: List, + text: String?, + ) { + runtime.navigator.navigate( + WireNavigation3ProductionCommandResolver.conversationFromShare( + requireSession(), + conversationId, + assets, + text, + ) + ) + } + + override fun openPublicLink(assetId: String, fileName: String, publicLinkId: String?) = + navigate( + PublicLinkRoute( + sessionId = requireSession(), + assetId = assetId, + fileName = fileName, + publicLinkId = publicLinkId, + isFolder = false, + ) + ) + + override fun completeInitialSync(route: InitialSyncRoute, shouldMoveToBackground: Boolean) { + // The remember wrapper observes Home before invoking the Activity effect, matching legacy. + pendingBackgroundAfterHomeSession = route.sessionId.takeIf { shouldMoveToBackground } + authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.HOME) + } + + override fun completeUnlock() = activity.completeAppLock() + override fun cancelUnlock() = activity.cancelAppLock() + override fun restartAfterLogout() = activity.restartAfterLogout() + + override fun exitCellsFlow() = goBackOrFinish() + + override fun exitMeetingFlow() = activity.finish() + + override fun openUserProfile(userId: MeetingParticipantId) = + openUserProfile(userId.value, userId.domain) + + internal fun moveTaskToBackgroundAfterHomeIsVisible() { + val expectedSession = pendingBackgroundAfterHomeSession + val current = runtime.navigator.currentRoute as? HomeRoute + if (expectedSession != null && current?.sessionId == expectedSession) { + pendingBackgroundAfterHomeSession = null + activity.moveTaskToBackground() + } + } + + private fun authenticationRoute(): WireRoute = + if (canUseNewLogin()) NewLoginRoute.start() else WelcomeRoute() + + private fun navigate( + route: WireRoute, + mode: WireBackStackMode = WireBackStackMode.NONE, + ) { + runtime.navigator.navigate(WireNavigationCommand(route, mode)) + } + + private fun goBackOrFinish() { + if (!runtime.navigator.goBack()) activity.finish() + } + + private fun requireSession(): WireSessionId = + checkNotNull(currentSessionId()) { "A session-backed Navigation 3 action requires an active session" } +} + +@Composable +internal fun rememberWireNavigation3ProductionActions( + runtime: WireNavigation3Runtime, + activity: WireNavigation3ActivityCallbacks, + currentSessionId: () -> WireSessionId?, + loginTypeSelector: LoginTypeSelector, + authenticationRouter: AuthenticationNavigation3Router, +): WireNavigation3ProductionActions { + val actions = remember(runtime, activity, currentSessionId, loginTypeSelector, authenticationRouter) { + WireNavigation3ProductionActions( + runtime, + activity, + currentSessionId, + loginTypeSelector, + authenticationRouter, + ) + } + val currentEntryId = runtime.navigator.currentRoute?.entryId + val pendingBackgroundSession = actions.pendingBackgroundAfterHomeSession + LaunchedEffect(currentEntryId, pendingBackgroundSession) { + actions.moveTaskToBackgroundAfterHomeIsVisible() + } + return actions +} + +internal object WireNavigation3ProductionCommandResolver { + fun completeLogin( + completion: AuthenticationLoginCompletion, + ): WireNavigationCommand = WireNavigationCommand( + destination = when (completion) { + is AuthenticationLoginCompletion.Home -> HomeRoute(completion.sessionId) + is AuthenticationLoginCompletion.InitialSync -> InitialSyncRoute(completion.sessionId) + is AuthenticationLoginCompletion.E2EIEnrollment -> E2EIEnrollmentRoute(completion.sessionId) + is AuthenticationLoginCompletion.RemoveDevice -> RemoveDeviceRoute(completion.sessionId) + }, + backStackMode = WireBackStackMode.CLEAR_WHOLE, + ) + + fun homeRequirement( + requirement: HomeRequirement, + currentSessionId: WireSessionId?, + ): WireNavigationCommand = WireNavigationCommand( + destination = when (requirement) { + is HomeRequirement.RegisterDevice -> + RegisterDeviceRoute(WireSessionId(requirement.userId.value, requirement.userId.domain)) + HomeRequirement.CreateAccountUsername -> CreateAccountUsernameRoute.start( + checkNotNull(currentSessionId) { + "$requirement requires the active session" + } + ) + HomeRequirement.InitialSync -> InitialSyncRoute( + checkNotNull(currentSessionId) { "InitialSync requires an active session" } + ) + }, + backStackMode = WireBackStackMode.CLEAR_WHOLE, + ) + + fun settings( + destination: SettingsNavigation3Destination, + sessionId: WireSessionId, + ): WireNavigationCommand? { + val route = when (destination) { + SettingsNavigation3Destination.ACCOUNT -> MyAccountRoute(sessionId) + SettingsNavigation3Destination.MANAGE_DEVICES -> SelfDevicesRoute(sessionId) + SettingsNavigation3Destination.DEBUG_SETTINGS -> DebugRoute(sessionId) + SettingsNavigation3Destination.ABOUT_APP -> AboutThisAppRoute(sessionId) + else -> return null + } + return WireNavigationCommand(route) + } + + fun conversationFromShare( + sessionId: WireSessionId, + conversationId: MediaConversationId, + assets: List, + text: String?, + ) = WireNavigationCommand( + destination = ConversationRoute( + sessionId, + ConversationRouteId(conversationId.value, conversationId.domain), + pendingAssets = assets.map(MediaAssetDto::toConversationAsset), + pendingText = text, + ), + backStackMode = WireBackStackMode.REMOVE_CURRENT_AND_REPLACE, + ) +} + +private fun SettingsNavigation3Destination.toExternalIntent() = + when (this) { + SettingsNavigation3Destination.TERMS_OF_USE -> WireNavigation3ExternalIntent.TERMS_OF_USE + SettingsNavigation3Destination.WIRE_WEBSITE -> WireNavigation3ExternalIntent.WIRE_WEBSITE + SettingsNavigation3Destination.PRIVACY_POLICY -> WireNavigation3ExternalIntent.PRIVACY_POLICY + SettingsNavigation3Destination.SUPPORT -> WireNavigation3ExternalIntent.SUPPORT + SettingsNavigation3Destination.REPORT_MISUSE -> WireNavigation3ExternalIntent.REPORT_MISUSE + SettingsNavigation3Destination.GIVE_FEEDBACK -> WireNavigation3ExternalIntent.GIVE_FEEDBACK + else -> error("$this is owned by a typed Navigation 3 route") + } + +private fun serviceTarget(value: String, domain: String, useNewAppsUi: Boolean) = + if (useNewAppsUi) { + ServiceProfileTarget.App(UserProfileQualifiedId(value, domain)) + } else { + ServiceProfileTarget.Bot(UserProfileQualifiedId(value, domain)) + } + +private fun MediaAssetDto.toConversationAsset() = ConversationPendingAsset( + key, + mimeType, + dataPath, + dataSize, + fileName, + assetType, + audioWavesMask +) + +private fun UserProfileQualifiedId.toConversationId() = ConversationRouteId(value, domain) +private fun UserProfileQualifiedId.toAuxId() = ConversationAuxId(value, domain) +private fun UserProfileQualifiedId.toMediaId() = MediaConversationId(value, domain) +private fun ConversationAuxId.toConversationId() = ConversationRouteId(value, domain) +private fun ConversationAuxId.toProfileId() = UserProfileQualifiedId(value, domain) +private fun ConversationDetailsId.toAuxId() = ConversationAuxId(value, domain) +private fun ConversationDetailsId.toProfileId() = UserProfileQualifiedId(value, domain) +private fun ConversationDetailsId.toMediaId() = MediaConversationId(value, domain) +private fun QualifiedID.toAuxId() = ConversationAuxId(value, domain) +private fun QualifiedID.toProfileId() = UserProfileQualifiedId(value, domain) + +private fun parseEligibleMember(value: String): ConversationAuxId? { + val separator = value.lastIndexOf('@') + if (separator <= 0 || separator == value.lastIndex) return null + return ConversationAuxId(value.substring(0, separator), value.substring(separator + 1)) +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHost.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHost.kt new file mode 100644 index 00000000000..285f0ef5841 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHost.kt @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.wire.android.di.metro.WireViewModelDiagnostics +import com.wire.android.navigation.navigation3.WireEntryEnvironment +import com.wire.android.navigation.navigation3.WireNav3Host +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.WireResponsivePresentationPolicy +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.navigation.stableKey + +/** + * Production composition root for the migrated application navigation surface. + * + * Runtime creation stays with WireActivity because startup, session transitions and process-state + * restoration are Activity policies. Once a runtime exists, this host owns the deterministic + * contribution catalog and the only Navigation 3 display. + */ +@Composable +internal fun WireNavigation3ProductionHost( + runtime: WireNavigation3Runtime, + actions: WireNavigation3CompositeActions, + authenticationRouter: AuthenticationNavigation3Router, + entryEnvironment: WireEntryEnvironment, + onRootBack: () -> Unit, + sharedViewModelStoreProvider: ViewModelStoreProvider, + modifier: Modifier = Modifier, + responsivePresentationPolicy: WireResponsivePresentationPolicy = + WireNavigation3ResponsivePresentationPolicy, +) { + val catalog = remember(runtime, actions, authenticationRouter) { + WireNavigation3Contributions.create(runtime, actions, authenticationRouter) + } + WireNav3Host( + runtime = runtime, + entryEnvironment = entryEnvironment, + entryProviderInstallers = catalog.entryProviderInstallers, + modifier = modifier, + onBack = { + navigateBackOrRunRootFallback(runtime.navigator::goBack, onRootBack) + }, + responsivePresentationPolicy = responsivePresentationPolicy, + sharedViewModelStoreProvider = sharedViewModelStoreProvider, + onViewModelOwnerAvailable = { ownerIdentity, owner -> + WireViewModelDiagnostics.ownerAvailable(owner, ownerIdentity.stableKey()) + }, + onViewModelOwnerReleased = { ownerIdentity, owner -> + WireViewModelDiagnostics.ownerReleased(owner, ownerIdentity.stableKey()) + }, + onViewModelOwnerCleared = WireViewModelDiagnostics::ownerCleared, + ) +} + +internal fun navigateBackOrRunRootFallback( + navigateBack: () -> Boolean, + onRootBack: () -> Unit, +) { + if (!navigateBack()) onRootBack() +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentation.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentation.kt new file mode 100644 index 00000000000..0b3ec0548b7 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentation.kt @@ -0,0 +1,68 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.navigation.navigation3.WireResponsivePresentationPolicy +import com.wire.android.ui.home.conversations.ConversationFoldersRoute +import com.wire.android.ui.home.conversations.NewConversationFolderRoute +import com.wire.android.ui.home.conversations.details.ChannelAccessOnUpdateRoute +import com.wire.android.ui.home.conversations.details.EditConversationNameRoute +import com.wire.android.ui.home.conversations.details.EditGuestAccessRoute +import com.wire.android.ui.home.conversations.details.EditSelfDeletingMessagesRoute +import com.wire.android.ui.home.conversations.details.GroupConversationDetailsRoute +import com.wire.android.ui.home.conversations.details.UpdateAppsAccessRoute +import com.wire.android.ui.home.settings.ChangeDisplayNameRoute +import com.wire.android.ui.home.settings.ChangeEmailRoute +import com.wire.android.ui.home.settings.ChangeHandleRoute +import com.wire.android.ui.home.settings.ChangeUserColorRoute +import com.wire.android.ui.settings.devices.DeviceDetailsRoute +import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerRoute +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.android.ui.userprofile.self.SelfUserProfileRoute +import com.wire.android.ui.userprofile.service.ServiceDetailsRoute +import com.wire.navigation.WireRoute +import kotlin.reflect.KClass + +/** Navigation 3 route types presented as dialogs on tablets. */ +internal val wireNavigation3TabletDialogRouteTypes: Set> = + setOf( + ServiceDetailsRoute::class, + OtherUserProfileRoute::class, + SelfUserProfileRoute::class, + DeviceDetailsRoute::class, + ChangeDisplayNameRoute::class, + ChangeHandleRoute::class, + ChangeEmailRoute::class, + AvatarPickerRoute::class, + GroupConversationDetailsRoute::class, + EditConversationNameRoute::class, + EditGuestAccessRoute::class, + UpdateAppsAccessRoute::class, + ChannelAccessOnUpdateRoute::class, + EditSelfDeletingMessagesRoute::class, + ConversationFoldersRoute::class, + NewConversationFolderRoute::class, + ChangeUserColorRoute::class, + ) + +/** + * Production tablet presentation policy. + */ +internal val WireNavigation3ResponsivePresentationPolicy: WireResponsivePresentationPolicy = + WireResponsivePresentationPolicy.tabletDialogsFor(wireNavigation3TabletDialogRouteTypes) diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt new file mode 100644 index 00000000000..9dcb83e630c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.navigation.routes.auth.AuthenticationLoginArguments +import com.wire.android.navigation.routes.auth.AuthenticationLoginPasswordPath +import com.wire.android.navigation.routes.auth.AuthenticationServerLinks +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute + +/** + * Navigation 3 account-switch bridge. + * + * Account use cases keep emitting semantic outcomes while the production action owner decides + * which typed route and back-stack mutation each outcome requires. + */ +internal class WireNavigation3SwitchAccountActions( + private val onSwitchedToAnotherAccount: () -> Unit, + private val onNoOtherAccountToSwitch: () -> Unit, +) : SwitchAccountActions { + override fun switchedToAnotherAccount() = onSwitchedToAnotherAccount() + + override fun noOtherAccountToSwitch() = onNoOtherAccountToSwitch() +} + +internal fun WireNavigation3ProductionActions.asSwitchAccountActions(): SwitchAccountActions = + WireNavigation3SwitchAccountActions( + onSwitchedToAnotherAccount = ::switchedToAnotherAccount, + onNoOtherAccountToSwitch = ::noOtherAccountToSwitch, + ) + +internal fun noOtherAccountNavigationCommand( + currentRoute: WireRoute?, + useNewLogin: Boolean, + recoveryServerLinks: AuthenticationServerLinks? = null, +): WireNavigationCommand? { + val loginArguments = AuthenticationLoginArguments( + loginPasswordPath = recoveryServerLinks?.let(::AuthenticationLoginPasswordPath), + ) + val destination = if (useNewLogin) { + NewLoginRoute.start(loginArguments) + } else { + WelcomeRoute(customServerConfig = recoveryServerLinks) + } + return destination + .takeUnless { currentRoute.hasEquivalentAuthenticationTarget(it) } + ?.let { WireNavigationCommand(it, WireBackStackMode.CLEAR_WHOLE) } +} + +private fun WireRoute?.hasEquivalentAuthenticationTarget(target: WireRoute): Boolean = when { + this is NewLoginRoute && target is NewLoginRoute -> args == target.args + this is WelcomeRoute && target is WelcomeRoute -> customServerConfig == target.customServerConfig + else -> false +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnostics.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnostics.kt new file mode 100644 index 00000000000..396a43e015f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnostics.kt @@ -0,0 +1,144 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.wire.android.BuildConfig +import com.wire.android.appLogger +import com.wire.android.di.metro.WireViewModelDiagnosticSink +import com.wire.navigation.WireBackStackChange +import com.wire.navigation.WireRoute +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Opt-in diagnostics shared by auth routing, Navigation 3, Metro graph resolution and ViewModels. + * + * Enable for a local build with `-Pwire.navigation.diagnostics=true`. Route arguments and account + * identifiers are deliberately excluded from the output. + */ +internal object WireNavigationDiagnostics : WireViewModelDiagnosticSink { + private const val PREFIX = "[WireNav3]" + private val sequence = AtomicLong() + private val ownerKeys = WeakHashMap() + private val stores = WeakHashMap() + + val enabled: Boolean + get() = BuildConfig.NAVIGATION_DIAGNOSTICS_ENABLED + + fun nextTransitionId(): Long = sequence.incrementAndGet() + + fun auth(transitionId: Long, event: String, outcome: String) = + log("AUTH", "id=$transitionId event=$event outcome=$outcome") + + fun navigation( + previous: List, + current: List, + change: WireBackStackChange, + ) = log( + "NAV", + "change=$change from=${previous.routeIds()} to=${current.routeIds()}", + ) + + fun metro(route: WireRoute, scope: String, outcome: String) = + log("METRO", "route=${route.routeId} scope=$scope outcome=$outcome") + + fun viewModel(type: String, factory: String) = + log("VM", "type=$type factory=$factory") + + @Synchronized + override fun ownerAvailable(owner: ViewModelStoreOwner, ownerKey: String) { + val store = owner.viewModelStore + val previousOwnerKey = ownerKeys[owner] + check(previousOwnerKey == null || previousOwnerKey == ownerKey) { + "One ViewModelStoreOwner cannot represent both " + + "${previousOwnerKey?.redactedOwnerKey()} and ${ownerKey.redactedOwnerKey()}" + } + val conflictingStore = ownerKeys + .asSequence() + .filter { (_, activeOwnerKey) -> activeOwnerKey == ownerKey } + .map { (activeOwner) -> activeOwner.viewModelStore } + .firstOrNull { activeStore -> activeStore !== store } + check(conflictingStore == null) { + "Active owner ${ownerKey.redactedOwnerKey()} maps to multiple ViewModelStores" + } + ownerKeys[owner] = ownerKey + val storeDiagnostics = stores.getOrPut(store) { StoreDiagnostics() } + if (previousOwnerKey == null) { + storeDiagnostics.references++ + } + log( + "OWNER", + "owner=${ownerKey.redactedOwnerKey()} store=${store.storeId()} " + + "outcome=${if (storeDiagnostics.references == 1) "acquire" else "reuse"}", + ) + } + + @Synchronized + override fun ownerReleased(owner: ViewModelStoreOwner, ownerKey: String) { + val store = owner.viewModelStore + if (ownerKeys.remove(owner) != null) { + stores[store]?.let { diagnostics -> + diagnostics.references = (diagnostics.references - 1).coerceAtLeast(0) + } + } + log( + "OWNER", + "owner=${ownerKey.redactedOwnerKey()} store=${store.storeId()} outcome=release", + ) + } + + @Synchronized + override fun ownerCleared(ownerKey: String) { + val matchingOwners = ownerKeys + .filterValues { it == ownerKey } + .keys + .toList() + val matchingStores = matchingOwners.map(ViewModelStoreOwner::viewModelStore).toSet() + matchingOwners.forEach(ownerKeys::remove) + matchingStores.forEach(stores::remove) + log("OWNER", "owner=${ownerKey.redactedOwnerKey()} outcome=clear") + } + + @Synchronized + override fun viewModelRequested(owner: ViewModelStoreOwner, viewModelKey: String) { + val store = owner.viewModelStore + val diagnostics = stores.getOrPut(store) { StoreDiagnostics() } + val firstRequest = diagnostics.viewModelKeys.add(viewModelKey) + log( + "VM", + "owner=${ownerKeys[owner]?.redactedOwnerKey() ?: "unregistered"} " + + "store=${store.storeId()} key=${viewModelKey.redactedViewModelKey()} " + + "outcome=${if (firstRequest) "create-request" else "reuse-request"}", + ) + } + + private fun log(channel: String, message: String) { + if (enabled) appLogger.i("$PREFIX[$channel] $message") + } + + private fun List.routeIds(): String = + joinToString(prefix = "[", postfix = "]") { it.routeId } + + private fun String.redactedOwnerKey(): String = + if (startsWith("session:")) "session:" else this + + private fun ViewModelStore.storeId(): String = + Integer.toHexString(System.identityHashCode(this)) + + private class StoreDiagnostics( + var references: Int = 0, + val viewModelKeys: MutableSet = mutableSetOf(), + ) +} + +internal fun String.redactedViewModelKey(): String = substringBefore(':') diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolver.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolver.kt new file mode 100644 index 00000000000..1f51c3da33f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolver.kt @@ -0,0 +1,71 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId + +/** + * Describes which Metro graph owns the dependencies used by a route. + * + * This intentionally contains no graph instance. Resolving route ownership is pure, while graph + * creation and lifecycle retention remain the responsibility of the application runtime. + */ +internal sealed interface WireRouteScope { + + data object Authentication : WireRouteScope + + data class Session(val sessionId: WireSessionId) : WireRouteScope + + data class Unavailable(val reason: Reason) : WireRouteScope { + enum class Reason { + ROUTE_HAS_NO_SCOPE, + ROUTE_HAS_MULTIPLE_SCOPES, + SESSION_GRAPH_INVALIDATING, + SESSION_GRAPH_REMOVED, + } + } +} + +/** + * Replaces generated destination-name matching with an exhaustive typed route-scope decision. + * + * Returning [WireRouteScope.Unavailable] is deliberate: an invalid route must never silently + * inherit the authentication graph or a graph belonging to another session. + */ +internal object WireRouteScopeResolver { + + fun resolve(route: WireRoute): WireRouteScope { + val isAuthenticationRoute = route is AuthenticationRoute + val isSessionRoute = route is SessionRoute + + return when { + isAuthenticationRoute && isSessionRoute -> + WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.ROUTE_HAS_MULTIPLE_SCOPES) + + isAuthenticationRoute -> WireRouteScope.Authentication + + isSessionRoute -> WireRouteScope.Session(route.sessionId) + + else -> WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.ROUTE_HAS_NO_SCOPE) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicy.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicy.kt new file mode 100644 index 00000000000..347022ef25a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicy.kt @@ -0,0 +1,82 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +/** + * Navigation-runtime-neutral snapshot used to reconcile session state with the visible route. + * + * Route classification deliberately happens at the host boundary and is derived exclusively from + * typed [com.wire.navigation.WireRoute] markers. + */ +internal data class WireSessionNavigationSnapshot( + val hasCurrentSession: Boolean, + val hasCurrentRoute: Boolean, + val isEmptyWelcomeRoute: Boolean, + val isAuthenticationRoute: Boolean, + val isSessionBackedAuthenticationRoute: Boolean, + val isUserUiBlocked: Boolean, + val isSessionTransitionInProgress: Boolean, + val isSelfLogoutTransition: Boolean, +) + +internal enum class WireSessionNavigationDecision { + NONE, + WAIT_FOR_BLOCKING_DIALOG, + NAVIGATE_HOME_CLEAR_STACK, + NAVIGATE_LOGIN_CLEAR_STACK, + RESOLVE_CURRENT_SESSION, +} + +internal object WireSessionNavigationPolicy { + + fun resolve(snapshot: WireSessionNavigationSnapshot): WireSessionNavigationDecision = when { + snapshot.isUserUiBlocked -> + WireSessionNavigationDecision.WAIT_FOR_BLOCKING_DIALOG + + snapshot.shouldNavigateHome -> + WireSessionNavigationDecision.NAVIGATE_HOME_CLEAR_STACK + + snapshot.shouldNavigateLogin -> + WireSessionNavigationDecision.NAVIGATE_LOGIN_CLEAR_STACK + + snapshot.isSessionTransitionInProgress -> + if (snapshot.shouldResolveTransition) { + WireSessionNavigationDecision.RESOLVE_CURRENT_SESSION + } else { + WireSessionNavigationDecision.NONE + } + + snapshot.shouldResolveMissingOrdinarySession -> + WireSessionNavigationDecision.RESOLVE_CURRENT_SESSION + + else -> WireSessionNavigationDecision.NONE + } +} + +private val WireSessionNavigationSnapshot.shouldNavigateHome + get() = hasCurrentSession && isEmptyWelcomeRoute + +private val WireSessionNavigationSnapshot.shouldNavigateLogin + get() = !hasCurrentSession && (isEmptyWelcomeRoute || isSessionBackedAuthenticationRoute) + +private val WireSessionNavigationSnapshot.shouldResolveTransition + get() = hasCurrentRoute && !isAuthenticationRoute && !isSelfLogoutTransition + +private val WireSessionNavigationSnapshot.shouldResolveMissingOrdinarySession + get() = !hasCurrentSession && hasCurrentRoute && !isAuthenticationRoute diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/startup/HomeRoute.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/startup/HomeRoute.kt new file mode 100644 index 00000000000..ca91dcaaded --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/startup/HomeRoute.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime.startup + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +/** + * Navigation 3 contract for the root of an authenticated session. + * + * [ROUTE_ID] deliberately preserves the former generated base route to keep analytics stable. + */ +@Serializable +data class HomeRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/home_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolver.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolver.kt new file mode 100644 index 00000000000..c18c86279c0 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolver.kt @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime.startup + +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.ui.InitialAppState +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId + +internal enum class WireStartupLoginType { + LEGACY, + NEW; + + companion object { + fun fromCanUseNewLogin(canUseNewLogin: Boolean): WireStartupLoginType = + if (canUseNewLogin) NEW else LEGACY + } +} + +/** + * Pure startup policy. It selects a typed route without knowing about NavController, Bundles, or + * generated Compose Destinations APIs. + */ +internal object WireInitialRouteResolver { + + fun resolve( + initialAppState: InitialAppState, + loginType: WireStartupLoginType, + activeSessionId: WireSessionId?, + ): WireRoute = when (initialAppState) { + InitialAppState.NotLoggedIn -> when (loginType) { + WireStartupLoginType.NEW -> NewWelcomeEmptyStartRoute() + WireStartupLoginType.LEGACY -> WelcomeRoute() + } + + is InitialAppState.EnrollE2EI -> E2EIEnrollmentRoute( + sessionId = initialAppState.userId.toWireSessionId(), + ) + + InitialAppState.LoggedIn -> HomeRoute( + sessionId = requireNotNull(activeSessionId) { + "A logged-in startup route requires the active session id" + }, + ) + } +} + +internal fun UserId.toWireSessionId(): WireSessionId = WireSessionId( + value = value, + domain = domain, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt index 5d424a8c51b..0e57b18d7d2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt @@ -17,91 +17,132 @@ */ package com.wire.android.ui +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.biometric.BiometricManager import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import com.ramcosta.composedestinations.generated.app.destinations.AppUnlockWithBiometricsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.EnterLockCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SetLockCodeScreenDestination import com.wire.android.appLogger -import com.wire.android.di.metro.LocalWireViewModelScopeKey -import com.wire.android.di.metro.createSessionViewModelGraph +import com.wire.android.di.metro.WireApplicationGraph import com.wire.android.di.metro.wireApplicationGraph -import com.wire.android.model.LocalWireSessionImageLoader -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.MainNavHost -import com.wire.android.navigation.rememberNavigator +import com.wire.android.navigation.navigation3.WireNav3Host +import com.wire.android.navigation.navigation3.rememberWireNavigation3Runtime +import com.wire.android.navigation.runtime.MetroWireEntryEnvironment +import com.wire.android.navigation.runtime.SessionGraphStoreViewModel +import com.wire.android.navigation.runtime.wireSessionGraphStoreViewModel import com.wire.android.ui.common.setupOrientationForDevice import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.home.appLock.AppLockNavigation3Actions +import com.wire.android.ui.home.appLock.AppLockNavigation3Contribution +import com.wire.android.ui.home.appLock.AppUnlockWithBiometricsRoute +import com.wire.android.ui.home.appLock.EnterLockCodeRoute +import com.wire.android.ui.home.appLock.SetLockCodeRoute +import com.wire.android.ui.home.appLock.resolveAppLockStartRoute import com.wire.android.ui.theme.WireTheme import com.wire.kalium.logic.data.id.QualifiedIdMapper -import dev.zacsweers.metro.Inject -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireSessionId class AppLockActivity : BaseActivity() { - @Inject - lateinit var loginTypeSelector: LoginTypeSelector - private val qualifiedIdMapper = QualifiedIdMapper(null) override fun onCreate(savedInstanceState: Bundle?) { - wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) - val sessionViewModelGraph = intent.getStringExtra(EXTRA_USER_ID) + val sessionId = intent.getStringExtra(EXTRA_USER_ID) ?.let(qualifiedIdMapper::fromStringToQualifiedID) - ?.let(wireApplicationGraph::createSessionViewModelGraph) + ?.let { WireSessionId(it.value, it.domain) } ?: run { appLogger.e("appLock: missing session user id, closing app lock activity") finish() return } + val setTeamAppLock = intent.getBooleanExtra(SET_TEAM_APP_LOCK, false) + val canAuthenticateWithBiometrics = BiometricManager + .from(this) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + val startRoute = resolveAppLockStartRoute( + sessionId = sessionId, + setTeamAppLock = setTeamAppLock, + canAuthenticateWithBiometrics = canAuthenticateWithBiometrics, + ) + when (startRoute) { + is SetLockCodeRoute -> appLogger.i("appLock: requesting set team app lock") + is AppUnlockWithBiometricsRoute -> + appLogger.i("appLock: requesting app Unlock with biometrics") + is EnterLockCodeRoute -> appLogger.i("appLock: requesting app Unlock with passcode") + } + setupOrientationForDevice() enableEdgeToEdge() setContent { - val snackbarHostState = remember { SnackbarHostState() } - val rememberedSessionViewModelGraph = remember { sessionViewModelGraph } - CompositionLocalProvider( - LocalSnackbarHostState provides snackbarHostState, - LocalMetroViewModelFactory provides rememberedSessionViewModelGraph.metroViewModelFactory, - LocalWireViewModelScopeKey provides rememberedSessionViewModelGraph.viewModelScopeKey, - LocalWireSessionImageLoader provides rememberedSessionViewModelGraph.wireSessionImageLoader, - LocalActivity provides this - ) { - WireTheme { - val navigator = rememberNavigator(finish = this@AppLockActivity::finish) + AppLockNavigation3Root( + appGraph = this@AppLockActivity.wireApplicationGraph, + startRoute = startRoute, + ) + } + } + + @Composable + private fun AppLockNavigation3Root( + appGraph: WireApplicationGraph, + startRoute: SessionRoute, + sessionGraphStore: SessionGraphStoreViewModel = wireSessionGraphStoreViewModel( + appGraph = appGraph, + owner = this@AppLockActivity, + ), + ) { + val snackbarHostState = remember { SnackbarHostState() } + val runtime = rememberWireNavigation3Runtime(startRoute) + val actions = remember { + object : AppLockNavigation3Actions { + override fun completeUnlock() = finish() + + override fun cancelUnlock() = finishAffinity() - val startDestination = - if (intent.getBooleanExtra(SET_TEAM_APP_LOCK, false)) { - appLogger.i("appLock: requesting set team app lock") - SetLockCodeScreenDestination() - } else { - val canAuthenticateWithBiometrics = BiometricManager - .from(this) - .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) - if (canAuthenticateWithBiometrics == BiometricManager.BIOMETRIC_SUCCESS) { - appLogger.i("appLock: requesting app Unlock with biometrics") - AppUnlockWithBiometricsScreenDestination() - } else { - appLogger.i("appLock: requesting app Unlock with passcode") - EnterLockCodeScreenDestination() - } - } + override fun restartAfterLogout() = this@AppLockActivity.restartAfterLogout() + } + } + val entryEnvironment = remember(appGraph, sessionGraphStore) { + MetroWireEntryEnvironment( + appGraph = appGraph, + authenticationGraph = appGraph.authenticationViewModelGraph, + sessionGraphStore = sessionGraphStore, + logoutAction = { this@AppLockActivity.restartAfterLogout() }, + ) + } + val entryProviderInstallers = remember(runtime, actions) { + AppLockNavigation3Contribution.entryProviderInstallers(runtime, actions) + } - MainNavHost( - navigator = navigator, - loginTypeSelector = loginTypeSelector, - startDestination = startDestination, - ) - } + CompositionLocalProvider( + LocalSnackbarHostState provides snackbarHostState, + LocalActivity provides this, + ) { + WireTheme { + WireNav3Host( + runtime = runtime, + entryEnvironment = entryEnvironment, + entryProviderInstallers = entryProviderInstallers, + ) } } } + private fun restartAfterLogout() { + startActivity( + Intent(this, WireActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + finish() + } + companion object { const val SET_TEAM_APP_LOCK = "set_team_app_lock" const val EXTRA_USER_ID = "user_id" diff --git a/app/src/main/kotlin/com/wire/android/ui/MiscViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/MiscViewModelGraph.kt index 680892b8690..74947a886a6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/MiscViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/MiscViewModelGraph.kt @@ -19,31 +19,26 @@ package com.wire.android.ui import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel -import com.wire.android.di.metro.MetroViewModelGraph -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.ui.analytics.AnalyticsUsageViewModel import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentViewModel import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateViewModel import com.wire.android.ui.initialsync.InitialSyncViewModel import com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel -import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedViewModel -import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel import com.wire.android.ui.sharing.ImportMediaAuthenticatedViewModel -import dev.zacsweers.metro.Provider - -interface MiscViewModelGraph : MetroViewModelGraph { - val legalHoldRequestedViewModel: Provider - val legalHoldDeactivatedViewModel: Provider -} @Composable -inline fun miscViewModel(): VM where VM : ViewModel = - sessionKeyedMetroViewModel() +inline fun miscViewModel( + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): VM where VM : ViewModel = + wireMetroViewModel(owner = owner) @Composable fun analyticsUsageViewModel(): AnalyticsUsageViewModel = - miscViewModel() + wireMetroViewModel() @Composable fun initialSyncViewModel(): InitialSyncViewModel = @@ -54,21 +49,23 @@ fun legalHoldRequestedViewModel(): LegalHoldRequestedViewModel = miscViewModel() @Composable -fun e2EIEnrollmentViewModel(): E2EIEnrollmentViewModel = - miscViewModel() +fun e2EIEnrollmentViewModel( + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): E2EIEnrollmentViewModel = + wireMetroViewModel(owner = owner) @Composable -fun getE2EICertificateViewModel(): GetE2EICertificateViewModel = - miscViewModel() - -@Composable -fun e2eiCertificateDetailsViewModel(): E2eiCertificateDetailsViewModel = - miscViewModel() +fun getE2EICertificateViewModel( + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): GetE2EICertificateViewModel = + miscViewModel(owner) @Composable fun importMediaAuthenticatedViewModel(): ImportMediaAuthenticatedViewModel = - miscViewModel() + wireMetroViewModel() @Composable -fun joinConversationViaCodeViewModel(): JoinConversationViaCodeViewModel = - miscViewModel() +fun joinConversationViaCodeViewModel( + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): JoinConversationViaCodeViewModel = + miscViewModel(owner) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 0815c26fa19..7b0f6601c0a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -18,174 +18,58 @@ package com.wire.android.ui -import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle import android.os.SystemClock import android.util.Log import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDataDetailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountEmailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSelectorScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSummaryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountUsernameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountVerificationCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreatePersonalAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateTeamAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.E2EiCertificateDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DebugScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LogManagementScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginVerificationCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewWelcomeEmptyStartScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RegisterDeviceScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfDevicesScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination -import com.ramcosta.composedestinations.spec.Direction import com.wire.android.BuildConfig import com.wire.android.WireApplication import com.wire.android.appLogger -import com.wire.android.config.CustomUiConfigurationProvider -import com.wire.android.config.LocalCustomUiConfigurationProvider -import com.wire.android.datastore.UserDataStore -import com.wire.android.di.LastKnownCurrentAccount -import com.wire.android.di.metro.AppAuthenticationViewModelGraph -import com.wire.android.di.metro.AppSessionViewModelGraph -import com.wire.android.di.metro.LocalWireViewModelScopeKey -import com.wire.android.di.metro.MetroViewModelGraph -import com.wire.android.di.metro.WireApplicationGraph -import com.wire.android.di.metro.createSessionViewModelGraph +import com.wire.android.di.metro.WireViewModelDiagnostics import com.wire.android.di.metro.wireApplicationGraph +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.emm.ManagedConfigurationsManager -import com.wire.android.feature.NavigationSwitchAccountActions -import com.wire.android.model.LocalWireSessionImageLoader -import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.MainNavHost -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.baseRoute -import com.wire.android.navigation.getBaseRoute -import com.wire.android.navigation.rememberNavigator -import com.wire.android.navigation.safeDestination -import com.wire.android.navigation.safeRoute -import com.wire.android.navigation.startDestination -import com.wire.android.navigation.style.BackgroundStyle -import com.wire.android.navigation.style.BackgroundType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.routes.utility.DebugRoute +import com.wire.android.navigation.routes.utility.LogManagementRoute +import com.wire.android.navigation.runtime.WireActivityIntentCoordinator +import com.wire.android.navigation.runtime.WireActivityIntentEffect +import com.wire.android.navigation.runtime.WireActivityIntentRequest +import com.wire.android.navigation.runtime.startup.WireInitialRouteResolver +import com.wire.android.navigation.runtime.startup.WireStartupLoginType +import com.wire.android.navigation.runtime.startup.toWireSessionId import com.wire.android.notification.broadcastreceivers.DynamicReceiversManager -import com.wire.android.ui.authentication.LocalAuthenticationCancelUserId -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs -import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout -import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState -import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.setupOrientationForDevice -import com.wire.android.ui.common.snackbar.LocalSnackbarHostState -import com.wire.android.ui.common.topappbar.CommonTopAppBarParams -import com.wire.android.ui.common.topappbar.CommonTopAppBarState -import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel -import com.wire.android.ui.common.topappbar.WireTopAppBar -import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateUI -import com.wire.android.ui.home.E2EICertificateRevokedDialog -import com.wire.android.ui.home.E2EIRequiredDialog -import com.wire.android.ui.home.E2EIResultDialog -import com.wire.android.ui.home.E2EISnoozeDialog -import com.wire.android.ui.home.FeatureFlagState import com.wire.android.ui.home.appLock.LockCodeTimeManager -import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel -import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedDialog -import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedState -import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedViewModel -import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedDialog -import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedState -import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel -import com.wire.android.ui.settings.devices.e2ei.E2EICertificateDetails -import com.wire.android.ui.sharing.hasTrustedWireShareCaller -import com.wire.android.ui.sharing.sharingUris -import com.wire.android.ui.theme.ThemeOption -import com.wire.android.ui.theme.WireTheme -import com.wire.android.ui.userprofile.self.LocalSelfUserProfileLogoutAction -import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog -import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager -import com.wire.android.util.LocalSyncStateObserver import com.wire.android.util.ShakeDetector import com.wire.android.util.SwitchAccountObserver -import com.wire.android.util.SyncStateObserver -import com.wire.android.util.debug.FeatureVisibilityFlags -import com.wire.android.util.debug.LocalFeatureVisibilityFlags -import com.wire.android.util.getProviderAuthority import com.wire.android.util.launchUpdateTheApp import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import dev.zacsweers.metro.Inject -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory -import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory -import dev.zacsweers.metrox.viewmodel.ViewModelGraph -@OptIn(ExperimentalComposeUiApi::class) @Suppress("TooManyFunctions", "LargeClass") class WireActivity : BaseActivity() { @@ -207,25 +91,14 @@ class WireActivity : BaseActivity() { @Inject lateinit var managedConfigurationsManager: ManagedConfigurationsManager - @Inject - lateinit var lastKnownCurrentAccount: LastKnownCurrentAccount - - private val viewModel: WireActivityViewModel by viewModels { - viewModelFactory { - initializer { - wireApplicationGraph.wireActivityViewModel - } - } + private val viewModel: WireActivityViewModel by lazy(LazyThreadSafetyMode.NONE) { + wireMetroViewModel( + owner = this, + factory = wireApplicationGraph.metroViewModelFactory, + ) } - private data class QueuedIntent( - val intent: Intent, - val savedInstanceState: Bundle?, - val hasTrustedWireShareCaller: Boolean - ) - - // Keep new intents until subscribed but do not replay them. - private val newIntents = Channel(Channel.UNLIMITED) + private val intentCoordinator = WireActivityIntentCoordinator() private lateinit var shakeDetector: ShakeDetector // This flag is used to keep the splash screen open until the first screen is drawn. @@ -242,8 +115,9 @@ class WireActivity : BaseActivity() { val splashScreen = installSplashScreen() wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) + WireViewModelDiagnostics.ownerAvailable(this, ACTIVITY_COORDINATOR_OWNER_KEY) splashScreen.setKeepOnScreenCondition { shouldKeepSplashOpen } - val initialQueuedIntent = captureInitialIntent(startupAt, savedInstanceState) + traceStartup("activity.onCreate.afterSuper", startupAt) enableEdgeToEdge() setupOrientationForDevice() @@ -257,20 +131,13 @@ class WireActivity : BaseActivity() { traceStartup("activity.initialAppState.start", startupAt) val initialAppState = viewModel.initialAppState() - val startDestination = when (initialAppState) { - InitialAppState.NotLoggedIn -> when (loginTypeSelector.canUseNewLogin()) { - true -> NewWelcomeEmptyStartScreenDestination() - false -> WelcomeScreenDestination() - } - - is InitialAppState.EnrollE2EI -> E2EIEnrollmentScreenDestination( - SessionBackedAuthenticationNavArgs.from(initialAppState.userId) - ) - - InitialAppState.LoggedIn -> HomeScreenDestination() - } - traceStartup("activity.initialAppState.resolved:$startDestination", startupAt) - setComposableContent(startDestination) + val initialRoute = WireInitialRouteResolver.resolve( + initialAppState = initialAppState, + loginType = WireStartupLoginType.fromCanUseNewLogin(loginTypeSelector.canUseNewLogin()), + activeSessionId = viewModel.globalAppState.currentUserId?.toWireSessionId(), + ) + traceStartup("activity.initialAppState.resolved:${initialRoute.routeId}", startupAt) + setComposableContent(initialRoute) traceStartup("activity.setContent.done", startupAt) // When the app is locked, get the app lock screen up before the splash screen is @@ -294,7 +161,7 @@ class WireActivity : BaseActivity() { (application as? WireApplication)?.initializeDeferredLoggingAfterSplash() traceStartup("activity.deferredLogging.triggered", startupAt) - handleNewIntent(initialQueuedIntent) + handleNewIntent(intent, savedInstanceState) traceStartup("activity.initialIntent.dispatched", startupAt) } @@ -335,43 +202,43 @@ class WireActivity : BaseActivity() { handleSynchronizeExternalData(intent) return } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - setIntentWithCurrentCaller(intent) - } else { - setIntent(intent) - } - handleNewIntent(queuedIntent(intent)) - } - - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) - private fun setIntentWithCurrentCaller(intent: Intent) { - setIntent(intent, getCurrentCaller()) - } - - private fun queuedIntent(intent: Intent, savedInstanceState: Bundle? = null): QueuedIntent { - val providerAuthority = getProviderAuthority() - return QueuedIntent( - intent = intent, - savedInstanceState = savedInstanceState, - hasTrustedWireShareCaller = hasTrustedWireShareCaller( - providerAuthority = providerAuthority, - uris = intent.sharingUris() - ) + setIntent(intent) + handleNewIntent(intent) + } + + private fun handleNewIntent(intent: Intent, savedInstanceState: Bundle? = null) { + intentCoordinator.enqueue(intent, savedInstanceState) + } + + private fun setComposableContent(startDestination: WireRoute) { + val hostDependencies = WireActivityHostDependencies( + activity = this, + viewModel = viewModel, + appGraph = wireApplicationGraph, + loginTypeSelector = loginTypeSelector, + intentCoordinator = intentCoordinator, + currentScreenManager = currentScreenManager, + switchAccountObserver = switchAccountObserver, + shakeEvents = shakeDetector.observeShakes(), + onIntentRequest = ::handleDeepLinkOrIntent, + onShake = ::handleShakeShortcut, + onStartTeamAppLock = { + startAppLockActivity(setTeamAppLock = true) + }, + onUpdateApp = ::updateTheApp, + onScreenshotCensoringChanged = { enabled -> + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + }, ) - } - - private fun captureInitialIntent(startupAt: Long, savedInstanceState: Bundle?): QueuedIntent { - traceStartup("activity.onCreate.afterSuper", startupAt) - return queuedIntent(intent, savedInstanceState) - } - - private fun handleNewIntent(queuedIntent: QueuedIntent) = lifecycleScope.launch { - newIntents.send(queuedIntent) - } - - private fun setComposableContent(startDestination: Direction) { setContent { - WireActivityRoot(startDestination) + WireActivityNavigation3Host( + startDestination = startDestination, + dependencies = hostDependencies, + ) } } @@ -380,815 +247,6 @@ class WireActivity : BaseActivity() { Log.i(TAG, "startup:$event$elapsed") } - @Composable - private fun WireActivityRoot( - startDestination: Direction, - appGraph: WireApplicationGraph = LocalContext.current.wireApplicationGraph, - sessionGraphStore: SessionGraphStoreViewModel = viewModel( - factory = viewModelFactory { - initializer { - SessionGraphStoreViewModel(appGraph::createSessionViewModelGraph) - } - } - ), - ) { - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - val authenticationViewModelGraph = remember(appGraph) { - appGraph.authenticationViewModelGraph - } - - CompositionLocalProvider( - LocalMetroViewModelFactory provides appGraph.metroViewModelFactory, - LocalWireViewModelScopeKey provides null, - LocalFeatureVisibilityFlags provides FeatureVisibilityFlags, - LocalSyncStateObserver provides SyncStateObserver(viewModel.observeSyncFlowState), - LocalCustomUiConfigurationProvider provides CustomUiConfigurationProvider, - LocalSnackbarHostState provides snackbarHostState, - LocalActivity provides this - ) { - HandleThemeChanges(viewModel.globalAppState.themeOption) - WireTheme(accent = viewModel.globalAppState.userAccent) { - WireActivityThemedContent( - startDestination = startDestination, - appGraph = appGraph, - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraphStore = sessionGraphStore, - context = context, - ) - } - } - } - - @Composable - private fun WireActivityThemedContent( - startDestination: Direction, - appGraph: WireApplicationGraph, - authenticationViewModelGraph: AppAuthenticationViewModelGraph, - sessionGraphStore: SessionGraphStoreViewModel, - context: Context, - ) { - val isUserUiBlocked = viewModel.globalAppState.blockUserUI != null - val navigator = rememberWireActivityNavigator( - isUserUiBlocked = isUserUiBlocked, - finish = this@WireActivity::finish, - isAllowedToNavigate = ::isNavigationAllowed - ) - val currentBackStackEntryState = wireActivityCurrentBackStackEntryAsState(navigator) - val currentBaseRoute = currentBackStackEntryState.value - ?.destination - ?.route - ?.getBaseRoute() - val currentUserId = viewModel.globalAppState.currentUserId - val sessionBackedAuthenticationUserId = currentBackStackEntryState.value - ?.arguments - ?.sessionBackedAuthenticationUserId() - val isAuthenticationRoute = currentBaseRoute in authenticationGraphRoutes - val isSessionTransitionInProgress = viewModel.globalAppState.isSessionTransitionInProgress - val sessionTransitionReason = viewModel.globalAppState.sessionTransitionReason - var noSessionAuthenticationStartedWithoutSession by remember { mutableStateOf(false) } - LaunchedEffect(currentBaseRoute, currentUserId) { - when { - currentUserId == null && currentBaseRoute in noSessionLoginAuthenticationRoutes -> - noSessionAuthenticationStartedWithoutSession = true - currentBaseRoute != null && currentBaseRoute !in authenticationGraphRoutes -> - noSessionAuthenticationStartedWithoutSession = false - } - } - val navHostStartDestination = resolveNavHostStartDestination( - initialStartDestination = startDestination, - currentUserId = currentUserId, - currentBaseRoute = currentBaseRoute, - noSessionAuthenticationStartedWithoutSession = noSessionAuthenticationStartedWithoutSession, - ) - LaunchedEffect(currentUserId) { - currentUserId?.let(lastKnownCurrentAccount::update) - } - val graphContext = rememberWireActivityGraphContext( - appGraph = appGraph, - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraphStore = sessionGraphStore, - currentUserId = currentUserId, - sessionBackedAuthenticationUserId = sessionBackedAuthenticationUserId, - currentBaseRoute = currentBaseRoute, - startDestinationBaseRoute = navHostStartDestination.baseRoute, - isUserUiBlocked = isUserUiBlocked, - isSessionTransitionInProgress = isSessionTransitionInProgress, - ) - val lastSessionGraphContext = remember { mutableStateOf(null) } - if (graphContext?.sessionGraph != null) { - lastSessionGraphContext.value = graphContext - } - val shouldInvalidateRetainedSessionGraph = shouldInvalidateWireActivitySessionGraph( - isUserUiBlocked = isUserUiBlocked, - sessionTransitionReason = sessionTransitionReason, - ) - LaunchedEffect(shouldInvalidateRetainedSessionGraph) { - lastSessionGraphContext.value = invalidateRetainedSessionGraphIfNeeded( - shouldInvalidate = shouldInvalidateRetainedSessionGraph, - lastSessionGraphContext = lastSessionGraphContext.value, - sessionGraphStore = sessionGraphStore, - ) - } - val graphContextWithRetainedImageLoader = graphContext?.copy( - imageLoaderSessionGraph = resolveWireActivityImageLoaderSessionGraph( - activeSessionGraph = graphContext.sessionGraph, - retainedSessionGraph = lastSessionGraphContext.value?.sessionGraph, - ) - ) - val canRenderSessionBackedRoute = currentUserId != null || sessionBackedAuthenticationUserId != null - val canRetainSessionGraphForContent = !isAuthenticationRoute && canRenderSessionBackedRoute - val contentGraphContext = graphContextWithRetainedImageLoader ?: when { - currentBaseRoute != null && canRetainSessionGraphForContent -> lastSessionGraphContext.value - else -> null - } - val backgroundType = currentBackStackEntryState.value?.safeDestination()?.style.let { - (it as? BackgroundStyle)?.backgroundType() ?: BackgroundType.Default - } - - HandleSessionGraphEffects( - currentUserId = currentUserId, - sessionBackedAuthenticationUserId = sessionBackedAuthenticationUserId, - currentBaseRoute = currentBaseRoute, - isAuthenticationRoute = isAuthenticationRoute, - isUserUiBlocked = isUserUiBlocked, - isSessionTransitionInProgress = isSessionTransitionInProgress, - graphContext = graphContext, - navigator = navigator, - ) - WireActivityMainContent( - startDestination = navHostStartDestination, - navigator = navigator, - graphContext = contentGraphContext, - backgroundType = backgroundType, - context = context, - ) - } - - private fun resolveNavHostStartDestination( - initialStartDestination: Direction, - currentUserId: UserId?, - currentBaseRoute: String?, - noSessionAuthenticationStartedWithoutSession: Boolean, - ): Direction = resolveWireActivityNavHostStartDestination( - initialStartDestination = initialStartDestination, - currentUserId = currentUserId, - currentBaseRoute = currentBaseRoute, - canUseNewLogin = loginTypeSelector.canUseNewLogin(), - noSessionAuthenticationStartedWithoutSession = noSessionAuthenticationStartedWithoutSession, - ) - - private fun invalidateRetainedSessionGraphIfNeeded( - shouldInvalidate: Boolean, - lastSessionGraphContext: WireActivityGraphContext?, - sessionGraphStore: SessionGraphStoreViewModel, - ): WireActivityGraphContext? { - if (!shouldInvalidate) return lastSessionGraphContext - - sessionGraphStore.invalidateActive() - return null - } - - private fun isNavigationAllowed(navigationCommand: NavigationCommand): Boolean { - if (navigationCommand.destination.baseRoute != NewLoginScreenDestination.baseRoute) return true - - // Enterprise login first needs to verify whether another session can be created. - return viewModel.checkNumberOfSessions() - } - - @Composable - private fun HandleSessionGraphEffects( - currentUserId: UserId?, - sessionBackedAuthenticationUserId: UserId?, - currentBaseRoute: String?, - isAuthenticationRoute: Boolean, - isUserUiBlocked: Boolean, - isSessionTransitionInProgress: Boolean, - graphContext: WireActivityGraphContext?, - navigator: Navigator, - ) { - graphContext?.activityViewModels?.let { - LaunchedEffect(it.legalHoldRequestedViewModel) { - it.legalHoldRequestedViewModel.observeLegalHoldRequest() - } - } - LaunchedEffect(currentBaseRoute, currentUserId, sessionBackedAuthenticationUserId, graphContext?.sessionGraph) { - appLogger.i( - "$TAG graph route=$currentBaseRoute userId=$currentUserId " + - "sessionGraph=${graphContext?.sessionGraph != null} " + - "selected=${graphContext?.graph?.viewModelScopeKey}" - ) - } - LaunchedEffect(isSessionTransitionInProgress, isAuthenticationRoute) { - if (isSessionTransitionInProgress && isAuthenticationRoute) { - viewModel.finishSessionTransition() - } - } - LaunchedEffect(currentUserId, currentBaseRoute, isSessionTransitionInProgress, isUserUiBlocked) { - handleSessionNavigationState( - SessionNavigationState( - currentUserId = currentUserId, - currentBaseRoute = currentBaseRoute, - isAuthenticationRoute = isAuthenticationRoute, - isSessionBackedAuthenticationRoute = currentBaseRoute in sessionBackedAuthenticationGraphRoutes, - isUserUiBlocked = isUserUiBlocked, - isSessionTransitionInProgress = isSessionTransitionInProgress, - ), - navigator = navigator, - ) - } - } - - @Composable - private fun WireActivityMainContent( - startDestination: Direction, - navigator: Navigator, - graphContext: WireActivityGraphContext?, - backgroundType: BackgroundType, - context: Context, - ) { - if (backgroundType == BackgroundType.Auth) { - WireAuthBackgroundLayout() - } - if (graphContext == null) { - HandleDialogs(navigator, null) - return - } - graphContext.ProvideViewModelGraph( - logoutAction = { wipeData -> - viewModel.doHardLogout( - clearUserData = { userId -> UserDataStore(context, userId) }, - switchAccountActions = NavigationSwitchAccountActions( - navigate = navigator::navigate, - canUseNewLogin = loginTypeSelector::canUseNewLogin - ), - wipeData = wipeData - ) - } - ) { - Column( - modifier = Modifier - .semantics { testTagsAsResourceId = true } - ) { - WireTopAppBar( - commonTopAppBarState = graphContext.activityViewModels - ?.commonTopAppBarViewModel - ?.state ?: CommonTopAppBarState(), - backgroundType = backgroundType, - ) - key(startDestination.route) { - MainNavHost( - navigator = navigator, - loginTypeSelector = loginTypeSelector, - startDestination = startDestination, - modifier = Modifier.consumeWindowInsets(WindowInsets.statusBars) - ) - } - - // Navigation graph creation is async enough that commands issued too early - // can crash before the graph is fully built. - SetUpNavigation(navigator) - HandleScreenshotCensoring() - HandleDialogs(navigator, graphContext.activityViewModels) - HandleViewActions(viewModel.actions, navigator, loginTypeSelector) - } - } - } - - @Composable - private fun rememberWireActivityGraphContext( - appGraph: WireApplicationGraph, - authenticationViewModelGraph: AppAuthenticationViewModelGraph, - sessionGraphStore: SessionGraphStoreViewModel, - currentUserId: UserId?, - sessionBackedAuthenticationUserId: UserId?, - currentBaseRoute: String?, - startDestinationBaseRoute: String, - isUserUiBlocked: Boolean, - isSessionTransitionInProgress: Boolean, - ): WireActivityGraphContext? { - if (isUserUiBlocked) return null - - val effectiveBaseRoute = currentBaseRoute ?: startDestinationBaseRoute - val usesNoSessionAuthenticationGraph = effectiveBaseRoute in noSessionAuthenticationGraphRoutes - val usesAuthenticationGraph = effectiveBaseRoute in authenticationGraphRoutes - val usesSessionBackedAuthenticationGraph = effectiveBaseRoute in sessionBackedAuthenticationGraphRoutes - val usesInvalidSessionBackedAuthenticationGraph = usesSessionBackedAuthenticationGraph && currentUserId == null - val retainedSessionGraph = remember( - appGraph, - currentUserId, - sessionBackedAuthenticationUserId, - usesNoSessionAuthenticationGraph, - usesInvalidSessionBackedAuthenticationGraph, - isSessionTransitionInProgress, - ) { - sessionGraphStore.resolveSessionGraph( - currentUserId = currentUserId, - sessionBackedAuthenticationUserId = sessionBackedAuthenticationUserId, - usesNoSessionAuthenticationGraph = usesNoSessionAuthenticationGraph, - usesInvalidSessionBackedAuthenticationGraph = usesInvalidSessionBackedAuthenticationGraph, - isSessionTransitionInProgress = isSessionTransitionInProgress, - ) - } - val sessionGraph = retainedSessionGraph?.graph - val graph = resolveActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = sessionGraph, - effectiveBaseRoute = effectiveBaseRoute, - currentBaseRoute = currentBaseRoute, - usesAuthenticationGraph = usesAuthenticationGraph, - usesNoSessionAuthenticationGraph = usesNoSessionAuthenticationGraph, - usesInvalidSessionBackedAuthenticationGraph = usesInvalidSessionBackedAuthenticationGraph, - isSessionTransitionInProgress = isSessionTransitionInProgress, - ) - ) - val activityViewModels = retainedSessionGraph?.let { - wireActivityScopedViewModels(it) - } - return graph?.let { - WireActivityGraphContext( - graph = it, - viewModelFactory = (it as? ViewModelGraph)?.metroViewModelFactory ?: appGraph.metroViewModelFactory, - sessionGraph = sessionGraph, - imageLoaderSessionGraph = sessionGraph, - activityViewModels = activityViewModels, - ) - } - } - - private fun SessionGraphStoreViewModel.resolveSessionGraph( - currentUserId: UserId?, - sessionBackedAuthenticationUserId: UserId?, - usesNoSessionAuthenticationGraph: Boolean, - usesInvalidSessionBackedAuthenticationGraph: Boolean, - isSessionTransitionInProgress: Boolean, - ): RetainedSessionGraph? = when { - usesNoSessionAuthenticationGraph -> null - usesInvalidSessionBackedAuthenticationGraph -> null - isSessionTransitionInProgress -> null - sessionBackedAuthenticationUserId != null -> retainedGraphFor(sessionBackedAuthenticationUserId) - currentUserId != null -> retainedGraphFor(currentUserId) - else -> null - } - - private fun resolveActiveGraph(request: WireActivityActiveGraphRequest): MetroViewModelGraph? = - resolveWireActivityActiveGraph(request) - - private fun handleSessionNavigationState( - state: SessionNavigationState, - navigator: Navigator, - ) { - when { - state.isUserUiBlocked -> { - appLogger.i("$TAG blocking session dialog visible on route=${state.currentBaseRoute}, waiting for user action") - } - state.currentUserId != null && state.currentBaseRoute == NewWelcomeEmptyStartScreenDestination.baseRoute -> { - appLogger.i("$TAG valid session on empty auth start, navigating to home") - navigator.navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) - } - state.currentUserId == null && state.currentBaseRoute == NewWelcomeEmptyStartScreenDestination.baseRoute -> { - appLogger.i("$TAG no session left on empty auth start, navigating to login") - navigator.navigate(NavigationCommand(NewLoginScreenDestination(), BackStackMode.CLEAR_WHOLE)) - } - state.currentUserId == null && state.isSessionBackedAuthenticationRoute -> { - appLogger.i("$TAG no session left on session-backed auth route=${state.currentBaseRoute}, trying to switch account") - resolveMissingCurrentSession(navigator) - } - state.isSessionTransitionInProgress -> { - handleSessionTransition(state.currentBaseRoute, state.isAuthenticationRoute, navigator) - } - state.currentUserId == null && state.currentBaseRoute != null && !state.isAuthenticationRoute -> { - appLogger.i("$TAG no session left on route=${state.currentBaseRoute}, trying to switch account") - resolveMissingCurrentSession(navigator) - } - } - } - - private fun handleSessionTransition( - currentBaseRoute: String?, - isAuthenticationRoute: Boolean, - navigator: Navigator, - ) { - if ( - currentBaseRoute != null && - !isAuthenticationRoute && - viewModel.globalAppState.sessionTransitionReason != SessionTransitionReason.SELF_LOGOUT - ) { - appLogger.i("$TAG session transition on route=$currentBaseRoute, resolving current session") - resolveMissingCurrentSession(navigator) - } - } - - private fun resolveMissingCurrentSession(navigator: Navigator) { - viewModel.resolveMissingCurrentSession( - NavigationSwitchAccountActions( - navigate = navigator::navigate, - canUseNewLogin = loginTypeSelector::canUseNewLogin, - ) - ) - } - - @Composable - private fun WireActivityGraphContext.ProvideViewModelGraph( - logoutAction: (wipeData: Boolean) -> Unit, - content: @Composable () -> Unit, - ) { - val imageLoader = imageLoaderSessionGraph?.wireSessionImageLoader - CompositionLocalProvider( - LocalMetroViewModelFactory provides viewModelFactory, - LocalWireViewModelScopeKey provides graph.viewModelScopeKey, - LocalAuthenticationCancelUserId provides sessionGraph?.currentAccount, - LocalWireSessionImageLoader provides imageLoader, - LocalSelfUserProfileLogoutAction provides logoutAction, - ) { - content() - } - } - - @Composable - private fun wireActivityScopedViewModels(retainedSessionGraph: RetainedSessionGraph): WireActivityScopedViewModels { - val graph = retainedSessionGraph.graph - val scopeKey = graph.viewModelScopeKey - return WireActivityScopedViewModels( - callFeedbackViewModel = viewModel( - viewModelStoreOwner = retainedSessionGraph, - key = "CallFeedbackViewModel:$scopeKey", - factory = graph.metroViewModelFactory, - ), - featureFlagNotificationViewModel = viewModel( - viewModelStoreOwner = retainedSessionGraph, - key = "FeatureFlagNotificationViewModel:$scopeKey", - factory = viewModelFactory { - initializer { - graph.featureFlagNotificationViewModel() - } - } - ), - commonTopAppBarViewModel = viewModel( - viewModelStoreOwner = retainedSessionGraph, - key = "CommonTopAppBarViewModel:$scopeKey", - factory = viewModelFactory { - initializer { - graph.commonTopAppBarViewModelFactory.create( - CommonTopAppBarParams(showNoNetwork = true, showSync = true, showActiveCalls = true) - ) - } - } - ), - legalHoldRequestedViewModel = viewModel( - viewModelStoreOwner = retainedSessionGraph, - key = "LegalHoldRequestedViewModel:$scopeKey", - factory = viewModelFactory { - initializer { - graph.legalHoldRequestedViewModel() - } - } - ), - legalHoldDeactivatedViewModel = viewModel( - viewModelStoreOwner = retainedSessionGraph, - key = "LegalHoldDeactivatedViewModel:$scopeKey", - factory = viewModelFactory { - initializer { - graph.legalHoldDeactivatedViewModel() - } - } - ), - ) - } - - @Composable - private fun HandleThemeChanges(themeOption: ThemeOption) { - LaunchedEffect(themeOption) { - val themeNightMode = when (themeOption) { - ThemeOption.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ThemeOption.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO - ThemeOption.DARK -> AppCompatDelegate.MODE_NIGHT_YES - } - val currentNightMode = AppCompatDelegate.getDefaultNightMode() - if (themeNightMode != currentNightMode) { - AppCompatDelegate.setDefaultNightMode(themeNightMode) - } - } - } - - @Composable - private fun SetUpNavigation(navigator: Navigator) { - val currentKeyboardController by rememberUpdatedState(LocalSoftwareKeyboardController.current) - val currentNavigator by rememberUpdatedState(navigator) - LaunchedEffect(Unit) { - lifecycleScope.launch { - newIntents - .receiveAsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .collectLatest { queuedIntent -> - currentKeyboardController?.hide() - handleDeepLinkOrIntent( - navigator = currentNavigator, - intent = queuedIntent.intent, - savedInstanceState = queuedIntent.savedInstanceState, - hasTrustedWireShareCaller = queuedIntent.hasTrustedWireShareCaller - ) - } - } - } - - DisposableEffect(navigator.navController) { - val updateScreenSettingsListener = - NavController.OnDestinationChangedListener { _, _, _ -> - currentKeyboardController?.hide() - } - navigator.navController.addOnDestinationChangedListener(updateScreenSettingsListener) - navigator.navController.addOnDestinationChangedListener(currentScreenManager) - - onDispose { - navigator.navController.removeOnDestinationChangedListener( - updateScreenSettingsListener - ) - navigator.navController.removeOnDestinationChangedListener(currentScreenManager) - } - } - - DisposableEffect(switchAccountObserver, navigator) { - NavigationSwitchAccountActions( - { - lifecycleScope.launch(Dispatchers.Main) { - navigator.navigate(it) - } - }, - loginTypeSelector::canUseNewLogin - ).let { - switchAccountObserver.register(it) - onDispose { - switchAccountObserver.unregister(it) - } - } - } - - LaunchedEffect(Unit) { - lifecycleScope.launch { - shakeDetector.observeShakes() - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .collectLatest { - handleShakeShortcut(currentNavigator) - } - } - } - } - - @Composable - private fun HandleScreenshotCensoring() { - LaunchedEffect(viewModel.globalAppState.screenshotCensoringEnabled) { - if (viewModel.globalAppState.screenshotCensoringEnabled) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } - } - - @Suppress("ComplexMethod") - @Composable - private fun HandleDialogs( - navigator: Navigator, - activityViewModels: WireActivityScopedViewModels?, - ) { - val navigate: (NavigationCommand) -> Unit = { navigator.navigate(it) } - val context = LocalContext.current - if (activityViewModels == null) { - UpdateAppDialog(viewModel.globalAppState.updateAppDialog, ::updateTheApp) - CustomBackendDialog( - state = viewModel.globalAppState.customBackendDialog, - onDismiss = { - viewModel.dismissCustomBackendDialog() - if (navigator.isEmptyWelcomeStartDestination()) { - navigate(NavigationCommand(NewLoginScreenDestination(), BackStackMode.CLEAR_WHOLE)) - } - }, - onConfirm = viewModel::customBackendDialogProceedButtonClicked, - onTryAgain = viewModel::onCustomServerConfig - ) - MaxAccountDialog( - shouldShow = viewModel.globalAppState.maxAccountDialog, - onConfirm = { - viewModel.dismissMaxAccountDialog() - navigate(NavigationCommand(SelfUserProfileScreenDestination)) - }, - onDismiss = viewModel::dismissMaxAccountDialog - ) - AccountLoggedOutDialog( - viewModel.globalAppState.blockUserUI - ) { - viewModel.tryToSwitchAccount() - } - CrossBackendLoginBlockedDialog( - shouldShow = viewModel.globalAppState.crossBackendLoginBlockedDialog, - onDismiss = viewModel::dismissCrossBackendLoginBlockedDialog - ) - return - } - val callFeedbackViewModel = activityViewModels.callFeedbackViewModel - val featureFlagNotificationViewModel = activityViewModels.featureFlagNotificationViewModel - val legalHoldRequestedViewModel = activityViewModels.legalHoldRequestedViewModel - val legalHoldDeactivatedViewModel = activityViewModels.legalHoldDeactivatedViewModel - val callFeedbackSheetState = - rememberWireModalSheetState(onDismissAction = { - callFeedbackViewModel.skipCallFeedback(false) - }) - with(featureFlagNotificationViewModel.featureFlagState) { - if (shouldShowTeamAppLockDialog) { - TeamAppLockFeatureFlagDialog( - isTeamAppLockEnabled = isTeamAppLockEnabled, - onConfirm = { - featureFlagNotificationViewModel.dismissTeamAppLockDialog() - if (isTeamAppLockEnabled) { - val isUserAppLockSet = featureFlagNotificationViewModel.featureFlagState.isUserAppLockSet - // No need to setup another app lock if the user already has one - if (!isUserAppLockSet) { - startAppLockActivity(setTeamAppLock = true) - } else { - featureFlagNotificationViewModel.markTeamAppLockStatusAsNot() - } - } else { - with(featureFlagNotificationViewModel) { - markTeamAppLockStatusAsNot() - confirmAppLockNotEnforced() - } - } - } - ) - } else { - if (legalHoldRequestedViewModel.state is LegalHoldRequestedState.Visible) { - LegalHoldRequestedDialog( - state = legalHoldRequestedViewModel.state as LegalHoldRequestedState.Visible, - passwordTextState = legalHoldRequestedViewModel.passwordTextState, - notNowClicked = legalHoldRequestedViewModel::notNowClicked, - acceptClicked = legalHoldRequestedViewModel::acceptClicked, - ) - } - if (legalHoldDeactivatedViewModel.state is LegalHoldDeactivatedState.Visible) { - LegalHoldDeactivatedDialog( - dialogDismissed = legalHoldDeactivatedViewModel::dismiss, - ) - } - if (showFileSharingDialog) { - FileRestrictionDialog( - isFileSharingEnabled = (isFileSharingState !is FeatureFlagState.FileSharingState.DisabledByTeam), - hideDialogStatus = featureFlagNotificationViewModel::dismissFileSharingDialog - ) - } - - if (shouldShowGuestRoomLinkDialog) { - GuestRoomLinkFeatureFlagDialog( - isGuestRoomLinkEnabled = isGuestRoomLinkEnabled, - onDismiss = featureFlagNotificationViewModel::dismissGuestRoomLinkDialog - ) - } - - if (shouldShowSelfDeletingMessagesDialog) { - SelfDeletingMessagesDialog( - areSelfDeletingMessagesEnabled = areSelfDeletedMessagesEnabled, - enforcedTimeout = enforcedTimeoutDuration, - hideDialogStatus = featureFlagNotificationViewModel::dismissSelfDeletingMessagesDialog - ) - } - val logoutOptionsDialogState = rememberVisibilityState() - - LogoutOptionsDialog( - dialogState = logoutOptionsDialogState, - checkboxEnabled = false, - logout = { - viewModel.doHardLogout( - { UserDataStore(context, it) }, - NavigationSwitchAccountActions(navigate, loginTypeSelector::canUseNewLogin) - ) - logoutOptionsDialogState.dismiss() - } - ) - - if (shouldShowE2eiCertificateRevokedDialog) { - E2EICertificateRevokedDialog( - onLogout = { - logoutOptionsDialogState.show( - LogoutOptionsDialogState( - shouldWipeData = true - ) - ) - }, - onContinue = featureFlagNotificationViewModel::dismissE2EICertificateRevokedDialog, - ) - } - - e2EIRequired?.let { - E2EIRequiredDialog( - e2EIRequired = e2EIRequired, - isE2EILoading = isE2EILoading, - getCertificate = featureFlagNotificationViewModel::enrollE2EICertificate, - snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog - ) - } - - e2EISnoozeInfo?.let { - E2EISnoozeDialog( - timeLeft = e2EISnoozeInfo.timeLeft, - dismissDialog = featureFlagNotificationViewModel::dismissSnoozeE2EIdRequiredDialog - ) - } - - e2EIResult?.let { - E2EIResultDialog( - result = e2EIResult, - updateCertificate = featureFlagNotificationViewModel::enrollE2EICertificate, - snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog, - openCertificateDetails = { - navigate( - NavigationCommand( - E2EiCertificateDetailsScreenDestination( - E2EICertificateDetails.DuringLoginCertificateDetails(it) - ) - ) - ) - }, - dismissSuccessDialog = featureFlagNotificationViewModel::dismissSuccessE2EIdDialog, - isE2EILoading = isE2EILoading - ) - } - - UpdateAppDialog(viewModel.globalAppState.updateAppDialog, ::updateTheApp) - JoinConversationDialog( - viewModel.globalAppState.conversationJoinedDialog, - navigate, - viewModel::onJoinConversationFlowCompleted - ) - CustomBackendDialog( - state = viewModel.globalAppState.customBackendDialog, - onDismiss = { - viewModel.dismissCustomBackendDialog() - if (navigator.isEmptyWelcomeStartDestination()) { - // if "welcome empty start" screen then switch "start" screen to proper one - navigate(NavigationCommand(NewLoginScreenDestination(), BackStackMode.CLEAR_WHOLE)) - } - }, - onConfirm = viewModel::customBackendDialogProceedButtonClicked, - onTryAgain = viewModel::onCustomServerConfig - ) - MaxAccountDialog( - shouldShow = viewModel.globalAppState.maxAccountDialog, - onConfirm = { - viewModel.dismissMaxAccountDialog() - navigate(NavigationCommand(SelfUserProfileScreenDestination)) - }, - onDismiss = viewModel::dismissMaxAccountDialog - ) - CrossBackendLoginBlockedDialog( - shouldShow = viewModel.globalAppState.crossBackendLoginBlockedDialog, - onDismiss = viewModel::dismissCrossBackendLoginBlockedDialog - ) - AccountLoggedOutDialog( - viewModel.globalAppState.blockUserUI - ) { viewModel.tryToSwitchAccount() } - NewClientDialog( - viewModel.globalAppState.newClientDialog, - { navigate(NavigationCommand(SelfDevicesScreenDestination)) }, - { - viewModel.switchAccount( - userId = it, - actions = NavigationSwitchAccountActions(navigate, loginTypeSelector::canUseNewLogin), - onComplete = { navigate(NavigationCommand(SelfDevicesScreenDestination)) } - ) - }, - viewModel::dismissNewClientsDialog - ) - } - if (showCallEndedBecauseOfConversationDegraded) { - GuestCallWasEndedBecauseOfVerificationDegradedDialog( - featureFlagNotificationViewModel::dismissCallEndedBecauseOfConversationDegraded - ) - } - - CallFeedbackDialog( - sheetState = callFeedbackSheetState, - onRated = callFeedbackViewModel::rateCall, - onSkipClicked = callFeedbackViewModel::skipCallFeedback - ) - - if (startGettingE2EICertificate) { - GetE2EICertificateUI( - enrollmentResultHandler = { - featureFlagNotificationViewModel.handleE2EIEnrollmentResult(it) - }, - isNewClient = false - ) - } - } - - LaunchedEffect(Unit) { - callFeedbackViewModel.showCallFeedbackFlow.collectLatest { - callFeedbackSheetState.show() - } - } - } - private fun updateTheApp() = this.launchUpdateTheApp() override fun onResume() { @@ -1229,14 +287,13 @@ class WireActivity : BaseActivity() { } override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean(HANDLED_DEEPLINK_FLAG, true) - outState.putParcelable(ORIGINAL_SAVED_INTENT_FLAG, intent) + intentCoordinator.saveInstanceState(outState, intent) super.onSaveInstanceState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - savedInstanceState.getOriginalIntent()?.let { + intentCoordinator.restoreActivityIntent(savedInstanceState)?.let { this.intent = it } } @@ -1254,95 +311,51 @@ class WireActivity : BaseActivity() { } } - @Suppress("ComplexCondition", "LongMethod", "CyclomaticComplexMethod") /* * This method is responsible for handling deep links from given intent */ private suspend fun handleDeepLinkOrIntent( - navigator: Navigator, - intent: Intent?, - savedInstanceState: Bundle? = null, - hasTrustedWireShareCaller: Boolean = false + runtime: WireNavigation3Runtime, + authenticationRouter: AuthenticationNavigation3Router, + request: WireActivityIntentRequest, ) { - val navigate: (NavigationCommand) -> Unit = { - runOnUiThread { - navigator.navigate(it) - } - } - val originalIntent = savedInstanceState.getOriginalIntent() - if (intent == null - || intent.action == Intent.ACTION_MAIN // The app is opened from launcher so no deep link to handle, only start intents if any - || intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 - || originalIntent == intent // This is the case when the activity is recreated and already handled - || intent.getBooleanExtra(HANDLED_DEEPLINK_FLAG, false) - ) { - val handled = viewModel.handleIntentsThatAreNotDeepLinks(intent) - if (!handled && navigator.isEmptyWelcomeStartDestination()) { - // nothing to handle so if "welcome empty start" screen then switch "start" screen to login by navigating to it - navigate(NavigationCommand(NewLoginScreenDestination(), BackStackMode.CLEAR_WHOLE)) - } - return - } else { - val handled = viewModel.handleIntentsThatAreNotDeepLinks(intent) - if (!handled) { - val providerAuthority = getProviderAuthority() - viewModel.handleDeepLink( - intent = intent, - providerAuthority = providerAuthority, - hasTrustedWireShareCaller = hasTrustedWireShareCaller - ) - intent.putExtra(HANDLED_DEEPLINK_FLAG, true) - } + val effect = intentCoordinator.handle( + request = request, + isEmptyWelcomeStartDestination = { + runtime.navigator.routes.firstOrNull() is NewWelcomeEmptyStartRoute + }, + handleNonDeepLinkIntent = { viewModel.handleIntentsThatAreNotDeepLinks(it) }, + handleDeepLink = { viewModel.handleDeepLink(it) }, + ) + if (effect == WireActivityIntentEffect.OPEN_LOGIN) { + authenticationRouter.openLoginFromActivity() } } - private fun handleShakeShortcut(navigator: Navigator) { - runOnUiThread { - val currentRoute = navigator.navController.currentDestination?.route?.getBaseRoute() - val shouldOpenDebugTools = BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED - val targetRoute = if (shouldOpenDebugTools) { - DebugScreenDestination.baseRoute - } else { - LogManagementScreenDestination.baseRoute - } - if (currentRoute == targetRoute) return@runOnUiThread - val target = if (shouldOpenDebugTools) { - DebugScreenDestination + private fun handleShakeShortcut(runtime: WireNavigation3Runtime) { + viewModel.globalAppState.currentUserId?.toWireSessionId()?.let { sessionId -> + val route = if (BuildConfig.PRIVATE_BUILD && BuildConfig.DEBUG_SCREEN_ENABLED) { + DebugRoute(sessionId) } else { - LogManagementScreenDestination + LogManagementRoute(sessionId) } - navigator.navigate(NavigationCommand(target, BackStackMode.UPDATE_EXISTED)) + runtime.navigator.navigate( + WireNavigationCommand(route, WireBackStackMode.UPDATE_EXISTING) + ) } } - private fun Bundle?.getOriginalIntent(): Intent? { - return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - @Suppress("DEPRECATION") // API 33 - this?.getParcelable(ORIGINAL_SAVED_INTENT_FLAG) - } else { - this?.getParcelable(ORIGINAL_SAVED_INTENT_FLAG, Intent::class.java) - } + override fun onDestroy() { + WireViewModelDiagnostics.ownerReleased(this, ACTIVITY_COORDINATOR_OWNER_KEY) + super.onDestroy() } companion object { - private const val HANDLED_DEEPLINK_FLAG = "deeplink_handled_flag_key" - private const val ORIGINAL_SAVED_INTENT_FLAG = "original_saved_intent" private const val TAG = "WireActivity" + private const val ACTIVITY_COORDINATOR_OWNER_KEY = "activity:wire" } } -@Composable -internal fun rememberWireActivityNavigator( - isUserUiBlocked: Boolean, - finish: () -> Unit, - isAllowedToNavigate: (NavigationCommand) -> Boolean, -): Navigator = key(isUserUiBlocked) { - rememberNavigator( - finish = finish, - isAllowedToNavigate = isAllowedToNavigate, - ) -} - internal fun observeAppLockUserId( isAppLocked: Flow, currentUserId: Flow, @@ -1350,216 +363,6 @@ internal fun observeAppLockUserId( if (isLocked) userId else null }.filterNotNull() -@Composable -internal fun wireActivityCurrentBackStackEntryAsState( - navigator: Navigator, -): State = key(navigator.navController) { - // collectAsState retains its previous value while changing flows, so reset its composition - // identity with the controller to avoid exposing a route from the discarded navigation graph. - navigator.navController.currentBackStackEntryAsState() -} - -private data class WireActivityScopedViewModels( - val callFeedbackViewModel: CallFeedbackViewModel, - val featureFlagNotificationViewModel: FeatureFlagNotificationViewModel, - val commonTopAppBarViewModel: CommonTopAppBarViewModel, - val legalHoldRequestedViewModel: LegalHoldRequestedViewModel, - val legalHoldDeactivatedViewModel: LegalHoldDeactivatedViewModel, -) - -private data class WireActivityGraphContext( - val graph: MetroViewModelGraph, - val viewModelFactory: MetroViewModelFactory, - val sessionGraph: AppSessionViewModelGraph?, - val imageLoaderSessionGraph: AppSessionViewModelGraph?, - val activityViewModels: WireActivityScopedViewModels?, -) - -internal class SessionGraphStoreViewModel( - private val createSessionGraph: (UserId) -> AppSessionViewModelGraph, -) : ViewModel() { - private val sessionGraphs = mutableMapOf() - private var activeUserId: UserId? = null - - fun retainedGraphFor(userId: UserId): RetainedSessionGraph { - activeUserId = userId - return sessionGraphs.getOrPut(userId) { - appLogger.i("WireActivity creating lifecycle-retained session graph for $userId") - RetainedSessionGraph(createSessionGraph(userId)) - } - } - - fun invalidateActive() { - activeUserId?.let(::invalidate) - } - - fun invalidate(userId: UserId) { - sessionGraphs.remove(userId)?.also { - appLogger.i("WireActivity clearing lifecycle-retained session graph for $userId") - it.clear() - } - if (activeUserId == userId) { - activeUserId = null - } - } - - override fun onCleared() { - sessionGraphs.values.forEach(RetainedSessionGraph::clear) - sessionGraphs.clear() - activeUserId = null - } -} - -@Stable -internal class RetainedSessionGraph( - val graph: AppSessionViewModelGraph, - override val viewModelStore: ViewModelStore = ViewModelStore(), -) : ViewModelStoreOwner { - - fun clear() { - viewModelStore.clear() - graph.wireSessionImageLoader.shutdown() - } -} - -internal data class WireActivityActiveGraphRequest( - val authenticationViewModelGraph: AppAuthenticationViewModelGraph, - val sessionGraph: AppSessionViewModelGraph?, - val effectiveBaseRoute: String, - val currentBaseRoute: String?, - val usesAuthenticationGraph: Boolean, - val usesNoSessionAuthenticationGraph: Boolean, - val usesInvalidSessionBackedAuthenticationGraph: Boolean, - val isSessionTransitionInProgress: Boolean, -) - -private data class SessionNavigationState( - val currentUserId: UserId?, - val currentBaseRoute: String?, - val isAuthenticationRoute: Boolean, - val isSessionBackedAuthenticationRoute: Boolean, - val isUserUiBlocked: Boolean, - val isSessionTransitionInProgress: Boolean, -) - -private val loginContinuationAuthenticationRoutes = setOf( - NewLoginPasswordScreenDestination.baseRoute, - NewLoginVerificationCodeScreenDestination.baseRoute, -) - -private val accountCreationAuthenticationRoutes = setOf( - CreateAccountSelectorScreenDestination.baseRoute, - CreateAccountDataDetailScreenDestination.baseRoute, - CreateAccountVerificationCodeScreenDestination.baseRoute, - CreatePersonalAccountOverviewScreenDestination.baseRoute, - CreateTeamAccountOverviewScreenDestination.baseRoute, - CreateAccountEmailScreenDestination.baseRoute, - CreateAccountDetailsScreenDestination.baseRoute, - CreateAccountCodeScreenDestination.baseRoute, - CreateAccountSummaryScreenDestination.baseRoute, - CreateAccountUsernameScreenDestination.baseRoute, -) - -private val noSessionAuthenticationGraphRoutes = loginContinuationAuthenticationRoutes + accountCreationAuthenticationRoutes - -private val noSessionLoginAuthenticationRoutes = setOf( - LoginScreenDestination.baseRoute, - NewLoginScreenDestination.baseRoute, -) + loginContinuationAuthenticationRoutes - -private val sessionBackedAuthenticationGraphRoutes = setOf( - RegisterDeviceScreenDestination.baseRoute, - RemoveDeviceScreenDestination.baseRoute, - E2EIEnrollmentScreenDestination.baseRoute, -) - -private val authenticationGraphRoutes = setOf( - WelcomeScreenDestination.baseRoute, - NewWelcomeEmptyStartScreenDestination.baseRoute, - LoginScreenDestination.baseRoute, - NewLoginScreenDestination.baseRoute, -) + noSessionAuthenticationGraphRoutes + sessionBackedAuthenticationGraphRoutes - -internal fun resolveWireActivityActiveGraph(request: WireActivityActiveGraphRequest): MetroViewModelGraph? = when { - request.usesInvalidSessionBackedAuthenticationGraph -> null - request.isSessionTransitionInProgress && !request.usesAuthenticationGraph -> null - request.usesNoSessionAuthenticationGraph -> request.authenticationViewModelGraph - request.effectiveBaseRoute in authenticationGraphRoutes && - request.effectiveBaseRoute !in sessionBackedAuthenticationGraphRoutes -> request.authenticationViewModelGraph - request.sessionGraph != null -> request.sessionGraph - request.currentBaseRoute == null && request.effectiveBaseRoute in authenticationGraphRoutes -> request.authenticationViewModelGraph - else -> null -} - -internal fun resolveWireActivityNavHostStartDestination( - initialStartDestination: Direction, - currentUserId: UserId?, - currentBaseRoute: String?, - canUseNewLogin: Boolean, - noSessionAuthenticationStartedWithoutSession: Boolean = false, -): Direction = when { - currentUserId != null && - noSessionAuthenticationStartedWithoutSession && - currentBaseRoute in noSessionLoginAuthenticationRoutes -> - resolveWireActivityLoggedOutStartDestination(canUseNewLogin) - - currentUserId != null || initialStartDestination.baseRoute in authenticationGraphRoutes -> - initialStartDestination - - currentBaseRoute == NewLoginScreenDestination.baseRoute || - currentBaseRoute == NewWelcomeEmptyStartScreenDestination.baseRoute || - currentBaseRoute in noSessionAuthenticationGraphRoutes -> - resolveWireActivityLoggedOutStartDestination(canUseNewLogin) - - currentBaseRoute == null -> - resolveWireActivityLoggedOutStartDestination(canUseNewLogin) - - currentBaseRoute == LoginScreenDestination.baseRoute || - currentBaseRoute == WelcomeScreenDestination.baseRoute -> - WelcomeScreenDestination() - - else -> initialStartDestination -} - -internal fun resolveWireActivityLoggedOutStartDestination(canUseNewLogin: Boolean): Direction = - when (canUseNewLogin) { - true -> NewWelcomeEmptyStartScreenDestination() - false -> WelcomeScreenDestination() - } - -internal fun resolveWireActivityImageLoaderSessionGraph( - activeSessionGraph: AppSessionViewModelGraph?, - retainedSessionGraph: AppSessionViewModelGraph?, -): AppSessionViewModelGraph? = activeSessionGraph ?: retainedSessionGraph - -internal fun shouldInvalidateWireActivitySessionGraph( - isUserUiBlocked: Boolean, - sessionTransitionReason: SessionTransitionReason?, -): Boolean = isUserUiBlocked || sessionTransitionReason != null - -private fun Bundle.sessionBackedAuthenticationUserId(): UserId? { - val value = getString(SessionBackedAuthenticationNavArgs.USER_ID_VALUE_KEY) - val domain = getString(SessionBackedAuthenticationNavArgs.USER_ID_DOMAIN_KEY) - return value?.let { userIdValue -> - domain?.let { userIdDomain -> - UserId(userIdValue, userIdDomain) - } - } -} - -internal fun Navigator.shouldReplaceWelcomeLoginStartDestination(): Boolean { - val firstDestinationBaseRoute = navController.startDestination()?.safeRoute()?.baseRoute - val welcomeScreens = listOf(WelcomeScreenDestination, NewWelcomeEmptyStartScreenDestination) - val loginScreens = listOf(LoginScreenDestination, NewLoginScreenDestination) - val welcomeAndLoginBaseRoutes = (welcomeScreens + loginScreens).map { it.baseRoute } - return welcomeAndLoginBaseRoutes.contains(firstDestinationBaseRoute) -} - -internal fun Navigator.isEmptyWelcomeStartDestination(): Boolean { - val firstDestinationBaseRoute = navController.startDestination()?.safeRoute()?.baseRoute - return firstDestinationBaseRoute == NewWelcomeEmptyStartScreenDestination.baseRoute -} - val LocalActivity = staticCompositionLocalOf { error("No Activity provided") } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt deleted file mode 100644 index 3885e9120e8..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityActionsHandler.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui - -import android.content.Context -import android.widget.Toast -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.safeRoute -import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType -import com.wire.android.ui.authentication.login.SSOCodeAutoLogin -import com.wire.android.ui.common.HandleActions -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination -import com.wire.android.ui.authentication.login.LoginPasswordPath -import com.wire.android.ui.newauthentication.login.NewLoginViewModel -import com.wire.android.ui.sharing.ImportMediaNavArgs -import com.wire.android.ui.sharing.ImportSource -import kotlinx.coroutines.flow.Flow -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -@Composable -internal fun HandleViewActions(actions: Flow, navigator: Navigator, loginTypeSelector: LoginTypeSelector) { - - val context = LocalContext.current - HandleActions(actions) { action -> - when (action) { - is OnAuthorizationNeeded -> onAuthorizationNeeded(context, navigator) - is OnMigrationLogin -> onMigration(navigator, loginTypeSelector, action) - is OnAutomaticLogin -> onAutomaticLogin(navigator, action) - is OnCustomBackendLogin -> onCustomBackendLogin(navigator, action) - is OnOpenUserProfile -> openUserProfile(action, navigator) - is OnSSOLogin -> openSsoLogin(navigator, action) - is OnShowImportMediaScreen -> openImportMediaScreen(navigator) - is OpenConversation -> openConversation(action, navigator) - is OnUnknownDeepLink -> if (navigator.isEmptyWelcomeStartDestination()) { - // log in needed so if "welcome empty start" screen then switch "start" screen to login by navigating to it - navigator.navigate(NavigationCommand(NewLoginScreenDestination(), BackStackMode.CLEAR_WHOLE)) - } - - is ShowToast -> showToast(context, action.messageResId) - } - } -} - -private fun openConversation(action: OpenConversation, navigator: Navigator) { - if (action.result.switchedAccount) { - navigator.navigate( - NavigationCommand( - HomeScreenDestination, - BackStackMode.CLEAR_WHOLE - ) - ) - } - navigator.navigate( - NavigationCommand( - ConversationScreenDestination(action.result.conversationId), - BackStackMode.UPDATE_EXISTED - ) - ) -} - -private fun openImportMediaScreen(navigator: Navigator) { - navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination(ImportMediaNavArgs(source = ImportSource.EXTERNAL_SHARE)), - BackStackMode.UPDATE_EXISTED - ) - ) -} - -private fun openSsoLogin(navigator: Navigator, action: OnSSOLogin) { - when (navigator.navController.currentBackStackEntry?.safeRoute()?.baseRoute) { - // if SSO login started from new login screen, deliver result via savedStateHandle - // to avoid recreating the ViewModel (which would happen with UPDATE_EXISTED + new nav args) - NewLoginScreenDestination.baseRoute -> { - navigator.navController.currentBackStackEntry - ?.savedStateHandle - ?.set(NewLoginViewModel.SSO_LOGIN_RESULT_KEY, Json.encodeToString(action.result)) - } - - else -> navigator.navigate( - NavigationCommand( - LoginScreenDestination(ssoLoginResult = action.result), - BackStackMode.UPDATE_EXISTED, - ) - ) - } -} - -private fun openUserProfile(action: OnOpenUserProfile, navigator: Navigator) { - if (action.result.switchedAccount) { - navigator.navigate( - NavigationCommand( - HomeScreenDestination, - BackStackMode.CLEAR_WHOLE - ) - ) - } - navigator.navigate( - NavigationCommand( - OtherUserProfileScreenDestination(action.result.userId), - BackStackMode.UPDATE_EXISTED - ) - ) -} - -private fun onMigration( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, - action: OnMigrationLogin -) { - navigator.navigate( - NavigationCommand( - when (loginTypeSelector.canUseNewLogin()) { - true -> NewLoginScreenDestination( - userHandle = PreFilledUserIdentifierType.PreFilled(action.result.userHandle) - ) - - false -> LoginScreenDestination( - userHandle = PreFilledUserIdentifierType.PreFilled(action.result.userHandle) - ) - }, - // if "welcome empty start" screen then switch "start" screen to proper one - when (navigator.shouldReplaceWelcomeLoginStartDestination()) { - true -> BackStackMode.CLEAR_WHOLE - false -> BackStackMode.UPDATE_EXISTED - }, - ) - ) -} - -private fun onAutomaticLogin( - navigator: Navigator, - action: OnAutomaticLogin -) { - // Auto-apply backend config AND navigate with SSO code pre-filled - val serverLinks = action.serverLinks - val ssoCode = action.ssoCode - val ssoCodeAutoLogin = ssoCode?.let { - SSOCodeAutoLogin( - ssoCode = it, - autoInitiateLogin = true, - nomadServiceUrl = action.nomadServiceUrl, - cookieLabel = action.cookieLabel, - ) - } - val destination = when (action.useNewLogin) { - true -> { - NewLoginScreenDestination( - loginPasswordPath = LoginPasswordPath(serverLinks), - ssoCodeAutoLogin = ssoCodeAutoLogin - ) - } - false -> { - LoginScreenDestination( - loginPasswordPath = LoginPasswordPath(serverLinks), - ssoCodeAutoLogin = ssoCodeAutoLogin - ) - } - } - - navigator.navigate( - NavigationCommand( - destination = destination, - backStackMode = when (navigator.shouldReplaceWelcomeLoginStartDestination()) { - true -> BackStackMode.CLEAR_WHOLE - else -> BackStackMode.UPDATE_EXISTED - } - ) - ) -} - -private fun onCustomBackendLogin( - navigator: Navigator, - action: OnCustomBackendLogin -) { - val destination = when (action.useNewLogin) { - true -> NewLoginScreenDestination( - loginPasswordPath = LoginPasswordPath(action.serverLinks), - showBackendConfigSuccess = true - ) - false -> WelcomeScreenDestination(customServerConfig = action.serverLinks) - } - - navigator.navigate( - NavigationCommand( - destination = destination, - backStackMode = when (navigator.shouldReplaceWelcomeLoginStartDestination()) { - true -> BackStackMode.CLEAR_WHOLE - else -> BackStackMode.UPDATE_EXISTED - } - ) - ) -} - -private fun onAuthorizationNeeded(context: Context, navigator: Navigator) { - if (navigator.isEmptyWelcomeStartDestination()) { - // log in needed so if "welcome empty start" screen then switch "start" screen to login by navigating to it - navigator.navigate(NavigationCommand(NewLoginScreenDestination(), BackStackMode.CLEAR_WHOLE)) - } - showToast(context, R.string.deeplink_authorization_needed) -} - -private fun showToast(context: Context, messageResId: Int) { - Toast.makeText( - context, - context.resources.getString(messageResId), - Toast.LENGTH_SHORT - ).show() -} diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt index c1649d15261..542a79cf85d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt @@ -16,7 +16,7 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -@file:Suppress("TooManyFunctions") +@file:Suppress("TooManyFunctions", "MatchingDeclarationName") package com.wire.android.ui @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -42,8 +43,6 @@ import androidx.compose.ui.text.style.TextAlign import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand import com.wire.android.ui.common.WireCheckbox import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -52,6 +51,7 @@ import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.bottomsheet.WireSheetValue import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dialogs.CustomServerDetailsDialog @@ -62,13 +62,26 @@ import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.common.dialogs.MaxAccountAllowedDialogContent import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.wireDialogPropertiesBuilder -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination +import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateUI +import com.wire.android.ui.home.E2EICertificateRevokedDialog +import com.wire.android.ui.home.E2EIRequiredDialog +import com.wire.android.ui.home.E2EIResultDialog +import com.wire.android.ui.home.E2EISnoozeDialog +import com.wire.android.ui.home.FeatureFlagState import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration import com.wire.android.ui.joinConversation.JoinConversationViaCodeState +import com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel import com.wire.android.ui.joinConversation.JoinConversationViaDeepLinkDialog import com.wire.android.ui.joinConversation.JoinConversationViaInviteLinkError +import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedDialog +import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedState +import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedDialog +import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedState import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog +import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.deeplink.LoginType import com.wire.android.util.deviceDateTimeFormat import com.wire.android.util.ui.PreviewMultipleThemes @@ -79,6 +92,241 @@ import com.wire.kalium.logic.data.user.UserId import kotlinx.datetime.Instant import com.wire.kalium.logic.feature.conversation.CheckConversationInviteCodeUseCase import java.text.NumberFormat +import kotlinx.coroutines.flow.collectLatest + +/** + * Navigation- and Activity-neutral effects emitted by the global dialog layer. + * + * The Navigation 3 host maps these callbacks to typed routes without changing dialog ordering or + * state ownership. + */ +internal data class WireActivityDialogActions( + val updateApp: () -> Unit, + val openLoginIfEmptyWelcomeStart: () -> Unit, + val openSelfProfile: () -> Unit, + val openSelfDevices: () -> Unit, + val switchAccountAndOpenSelfDevices: (UserId) -> Unit, + val openE2EICertificateDetails: (String) -> Unit, + val openJoinedConversation: (ConversationId) -> Unit, + val startTeamAppLock: () -> Unit, + val hardLogout: () -> Unit, + val recoverFromLoggedOutSession: () -> Unit, +) + +@Suppress("ComplexMethod") +@Composable +internal fun WireActivityDialogs( + viewModel: WireActivityViewModel, + activityViewModels: WireActivityScopedViewModels?, + actions: WireActivityDialogActions, +) { + if (activityViewModels == null) { + UpdateAppDialog(viewModel.globalAppState.updateAppDialog, actions.updateApp) + CustomBackendDialog( + state = viewModel.globalAppState.customBackendDialog, + onDismiss = { + viewModel.dismissCustomBackendDialog() + actions.openLoginIfEmptyWelcomeStart() + }, + onConfirm = viewModel::customBackendDialogProceedButtonClicked, + onTryAgain = viewModel::onCustomServerConfig, + ) + MaxAccountDialog( + shouldShow = viewModel.globalAppState.maxAccountDialog, + onConfirm = { + viewModel.dismissMaxAccountDialog() + actions.openSelfProfile() + }, + onDismiss = viewModel::dismissMaxAccountDialog, + ) + AccountLoggedOutDialog(viewModel.globalAppState.blockUserUI) { + actions.recoverFromLoggedOutSession() + } + CrossBackendLoginBlockedDialog( + shouldShow = viewModel.globalAppState.crossBackendLoginBlockedDialog, + onDismiss = viewModel::dismissCrossBackendLoginBlockedDialog, + ) + return + } + + val callFeedbackViewModel = activityViewModels.callFeedbackViewModel + val featureFlagNotificationViewModel = activityViewModels.featureFlagNotificationViewModel + val legalHoldRequestedViewModel = activityViewModels.legalHoldRequestedViewModel + val legalHoldDeactivatedViewModel = activityViewModels.legalHoldDeactivatedViewModel + val callFeedbackSheetState = + rememberWireModalSheetState(onDismissAction = { + callFeedbackViewModel.skipCallFeedback(false) + }) + + with(featureFlagNotificationViewModel.featureFlagState) { + if (shouldShowTeamAppLockDialog) { + TeamAppLockFeatureFlagDialog( + isTeamAppLockEnabled = isTeamAppLockEnabled, + onConfirm = { + featureFlagNotificationViewModel.dismissTeamAppLockDialog() + if (isTeamAppLockEnabled) { + val isUserAppLockSet = + featureFlagNotificationViewModel.featureFlagState.isUserAppLockSet + if (!isUserAppLockSet) { + actions.startTeamAppLock() + } else { + featureFlagNotificationViewModel.markTeamAppLockStatusAsNot() + } + } else { + with(featureFlagNotificationViewModel) { + markTeamAppLockStatusAsNot() + confirmAppLockNotEnforced() + } + } + }, + ) + } else { + if (legalHoldRequestedViewModel.state is LegalHoldRequestedState.Visible) { + LegalHoldRequestedDialog( + state = legalHoldRequestedViewModel.state as LegalHoldRequestedState.Visible, + passwordTextState = legalHoldRequestedViewModel.passwordTextState, + notNowClicked = legalHoldRequestedViewModel::notNowClicked, + acceptClicked = legalHoldRequestedViewModel::acceptClicked, + ) + } + if (legalHoldDeactivatedViewModel.state is LegalHoldDeactivatedState.Visible) { + LegalHoldDeactivatedDialog( + dialogDismissed = legalHoldDeactivatedViewModel::dismiss, + ) + } + if (showFileSharingDialog) { + FileRestrictionDialog( + isFileSharingEnabled = + isFileSharingState !is FeatureFlagState.FileSharingState.DisabledByTeam, + hideDialogStatus = featureFlagNotificationViewModel::dismissFileSharingDialog, + ) + } + if (shouldShowGuestRoomLinkDialog) { + GuestRoomLinkFeatureFlagDialog( + isGuestRoomLinkEnabled = isGuestRoomLinkEnabled, + onDismiss = featureFlagNotificationViewModel::dismissGuestRoomLinkDialog, + ) + } + if (shouldShowSelfDeletingMessagesDialog) { + SelfDeletingMessagesDialog( + areSelfDeletingMessagesEnabled = areSelfDeletedMessagesEnabled, + enforcedTimeout = enforcedTimeoutDuration, + hideDialogStatus = + featureFlagNotificationViewModel::dismissSelfDeletingMessagesDialog, + ) + } + + val logoutOptionsDialogState = rememberVisibilityState() + LogoutOptionsDialog( + dialogState = logoutOptionsDialogState, + checkboxEnabled = false, + logout = { + actions.hardLogout() + logoutOptionsDialogState.dismiss() + }, + ) + if (shouldShowE2eiCertificateRevokedDialog) { + E2EICertificateRevokedDialog( + onLogout = { + logoutOptionsDialogState.show( + LogoutOptionsDialogState(shouldWipeData = true) + ) + }, + onContinue = + featureFlagNotificationViewModel::dismissE2EICertificateRevokedDialog, + ) + } + e2EIRequired?.let { + E2EIRequiredDialog( + e2EIRequired = e2EIRequired, + isE2EILoading = isE2EILoading, + getCertificate = featureFlagNotificationViewModel::enrollE2EICertificate, + snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog, + ) + } + e2EISnoozeInfo?.let { + E2EISnoozeDialog( + timeLeft = e2EISnoozeInfo.timeLeft, + dismissDialog = featureFlagNotificationViewModel::dismissSnoozeE2EIdRequiredDialog, + ) + } + e2EIResult?.let { + E2EIResultDialog( + result = e2EIResult, + updateCertificate = featureFlagNotificationViewModel::enrollE2EICertificate, + snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog, + openCertificateDetails = actions.openE2EICertificateDetails, + dismissSuccessDialog = + featureFlagNotificationViewModel::dismissSuccessE2EIdDialog, + isE2EILoading = isE2EILoading, + ) + } + + UpdateAppDialog(viewModel.globalAppState.updateAppDialog, actions.updateApp) + JoinConversationDialog( + joinedDialogState = viewModel.globalAppState.conversationJoinedDialog, + openConversation = actions.openJoinedConversation, + onJoinConversationFlowCompleted = viewModel::onJoinConversationFlowCompleted, + viewModel = activityViewModels.joinConversationViaCodeViewModel, + ) + CustomBackendDialog( + state = viewModel.globalAppState.customBackendDialog, + onDismiss = { + viewModel.dismissCustomBackendDialog() + actions.openLoginIfEmptyWelcomeStart() + }, + onConfirm = viewModel::customBackendDialogProceedButtonClicked, + onTryAgain = viewModel::onCustomServerConfig, + ) + MaxAccountDialog( + shouldShow = viewModel.globalAppState.maxAccountDialog, + onConfirm = { + viewModel.dismissMaxAccountDialog() + actions.openSelfProfile() + }, + onDismiss = viewModel::dismissMaxAccountDialog, + ) + CrossBackendLoginBlockedDialog( + shouldShow = viewModel.globalAppState.crossBackendLoginBlockedDialog, + onDismiss = viewModel::dismissCrossBackendLoginBlockedDialog, + ) + AccountLoggedOutDialog(viewModel.globalAppState.blockUserUI) { + actions.recoverFromLoggedOutSession() + } + NewClientDialog( + data = viewModel.globalAppState.newClientDialog, + openDeviceManager = actions.openSelfDevices, + switchAccountAndOpenDeviceManager = actions.switchAccountAndOpenSelfDevices, + dismiss = viewModel::dismissNewClientsDialog, + ) + } + + if (showCallEndedBecauseOfConversationDegraded) { + GuestCallWasEndedBecauseOfVerificationDegradedDialog( + featureFlagNotificationViewModel::dismissCallEndedBecauseOfConversationDegraded + ) + } + CallFeedbackDialog( + sheetState = callFeedbackSheetState, + onRated = callFeedbackViewModel::rateCall, + onSkipClicked = callFeedbackViewModel::skipCallFeedback, + ) + if (startGettingE2EICertificate) { + GetE2EICertificateUI( + enrollmentResultHandler = + featureFlagNotificationViewModel::handleE2EIEnrollmentResult, + isNewClient = false, + viewModel = activityViewModels.getE2EICertificateViewModel, + ) + } + } + + LaunchedEffect(Unit) { + callFeedbackViewModel.showCallFeedbackFlow.collectLatest { + callFeedbackSheetState.show() + } + } +} @Composable fun FileRestrictionDialog( @@ -206,22 +454,16 @@ fun UpdateAppDialog(shouldShow: Boolean, onUpdateClick: () -> Unit) { @Composable fun JoinConversationDialog( joinedDialogState: JoinConversationViaCodeState?, - navigate: (NavigationCommand) -> Unit, - onJoinConversationFlowCompleted: () -> Unit + openConversation: (ConversationId) -> Unit, + viewModel: JoinConversationViaCodeViewModel = joinConversationViaCodeViewModel(), + onJoinConversationFlowCompleted: () -> Unit, ) { joinedDialogState?.let { state -> val onComplete: (convId: ConversationId?) -> Unit = remember { { onJoinConversationFlowCompleted() - it?.also { - navigate( - NavigationCommand( - ConversationScreenDestination(it), - BackStackMode.CLEAR_TILL_START - ) - ) - } + it?.let(openConversation) } } @@ -238,7 +480,8 @@ fun JoinConversationDialog( domain = state.domain, key = state.key, requirePassword = state.passwordProtected, - onFlowCompleted = onComplete + onFlowCompleted = onComplete, + viewModel = viewModel, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3DialogActions.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3DialogActions.kt new file mode 100644 index 00000000000..682f5cab9c9 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3DialogActions.kt @@ -0,0 +1,111 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.ui + +import android.content.Context +import com.wire.android.datastore.UserDataStore +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.runtime.startup.toWireSessionId +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.home.conversations.ConversationRouteId +import com.wire.android.ui.settings.devices.SelfDevicesRoute +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsPayload +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsRoute +import com.wire.android.ui.userprofile.self.SelfUserProfileRoute +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute + +internal data class WireActivityDialogActionDependencies( + val context: Context, + val viewModel: WireActivityViewModel, + val updateApp: () -> Unit, + val startTeamAppLock: () -> Unit, +) + +@Suppress("LongMethod") +internal fun navigation3DialogActions( + runtime: WireNavigation3Runtime, + switchAccountActions: SwitchAccountActions, + dependencies: WireActivityDialogActionDependencies, +): WireActivityDialogActions { + val sessionId = { + dependencies.viewModel.globalAppState.currentUserId?.toWireSessionId() + } + val navigate: (WireRoute, WireBackStackMode) -> Unit = { route, mode -> + runtime.navigator.navigate(WireNavigationCommand(route, mode)) + } + return WireActivityDialogActions( + updateApp = dependencies.updateApp, + openLoginIfEmptyWelcomeStart = { + if (runtime.navigator.routes.firstOrNull() is NewWelcomeEmptyStartRoute) { + navigate(NewLoginRoute.start(), WireBackStackMode.CLEAR_WHOLE) + } + }, + openSelfProfile = { + sessionId()?.let { navigate(SelfUserProfileRoute(it), WireBackStackMode.NONE) } + }, + openSelfDevices = { + sessionId()?.let { navigate(SelfDevicesRoute(it), WireBackStackMode.NONE) } + }, + switchAccountAndOpenSelfDevices = { userId -> + dependencies.viewModel.switchAccount( + userId = userId, + actions = switchAccountActions, + onComplete = { + sessionId()?.let { + navigate(SelfDevicesRoute(it), WireBackStackMode.NONE) + } + }, + ) + }, + openE2EICertificateDetails = { certificate -> + sessionId()?.let { + navigate( + E2eiCertificateDetailsRoute( + sessionId = it, + details = E2eiCertificateDetailsPayload.DuringLogin(certificate), + ), + WireBackStackMode.NONE, + ) + } + }, + openJoinedConversation = { conversationId -> + sessionId()?.let { + navigate( + ConversationRoute( + sessionId = it, + conversationId = ConversationRouteId( + conversationId.value, + conversationId.domain, + ), + ), + WireBackStackMode.CLEAR_TILL_START, + ) + } + }, + startTeamAppLock = dependencies.startTeamAppLock, + hardLogout = { + dependencies.viewModel.doHardLogout( + clearUserData = { UserDataStore(dependencies.context, it) }, + switchAccountActions = switchAccountActions, + ) + }, + recoverFromLoggedOutSession = { + dependencies.viewModel.tryToSwitchAccount(switchAccountActions) + }, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Effects.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Effects.kt new file mode 100644 index 00000000000..7c9df02dbed --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Effects.kt @@ -0,0 +1,361 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.wire.android.R +import com.wire.android.navigation.LoginTypeSelector +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.auth.AuthenticationLoginArguments +import com.wire.android.navigation.routes.auth.AuthenticationLoginPasswordPath +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.navigation.routes.auth.AuthenticationPrefilledUserIdentifier +import com.wire.android.navigation.routes.auth.AuthenticationSsoCodeAutoLogin +import com.wire.android.navigation.routes.auth.LoginRoute +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.navigation.routes.auth.toAuthenticationServerLinks +import com.wire.android.navigation.routes.auth.toAuthenticationSsoLoginResult +import com.wire.android.navigation.routes.media.AuthenticatedImportMediaRoute +import com.wire.android.navigation.routes.media.LoggedOutImportMediaRoute +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.common.HandleActions +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.home.conversations.ConversationRouteId +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import kotlinx.coroutines.flow.Flow + +@Composable +internal fun HandleNavigation3ViewActions( + actions: Flow, + runtime: WireNavigation3Runtime, + authenticationRouter: AuthenticationNavigation3Router, + loginTypeSelector: LoginTypeSelector, + currentSessionId: () -> WireSessionId?, +) { + val context = LocalContext.current + HandleActions(actions) { action -> + val resolution = WireActivityNavigation3EffectResolver.resolve( + action = action, + routes = runtime.navigator.routes, + currentSessionId = currentSessionId(), + canUseNewLogin = loginTypeSelector.canUseNewLogin(), + ) + resolution.mutations.forEach { mutation -> + when (mutation) { + is WireActivityNavigation3Mutation.Navigate -> + authenticationRouter.navigate( + AuthenticationNavigationTransition.ACTIVITY_EFFECT, + mutation.command, + ) + + is WireActivityNavigation3Mutation.ReplaceCurrent -> + authenticationRouter.replaceCurrent( + AuthenticationNavigationTransition.ACTIVITY_EFFECT, + mutation.route, + ) + } + } + resolution.toastMessageResId?.let { showNavigation3Toast(context, it) } + } +} + +internal data class WireActivityNavigation3EffectResolution( + val mutations: List = emptyList(), + val toastMessageResId: Int? = null, +) + +internal sealed interface WireActivityNavigation3Mutation { + data class Navigate(val command: WireNavigationCommand) : WireActivityNavigation3Mutation + data class ReplaceCurrent(val route: WireRoute) : WireActivityNavigation3Mutation +} + +@Suppress("TooManyFunctions") +internal object WireActivityNavigation3EffectResolver { + + fun resolve( + action: WireActivityViewAction, + routes: List, + currentSessionId: WireSessionId?, + canUseNewLogin: Boolean, + ): WireActivityNavigation3EffectResolution = when (action) { + OnAuthorizationNeeded -> authorizationNeeded(routes) + is OnMigrationLogin -> migration(action, routes, canUseNewLogin) + is OnAutomaticLogin -> automaticLogin(action, routes) + is OnCustomBackendLogin -> customBackendLogin(action, routes) + is OnOpenUserProfile -> openUserProfile(action, currentSessionId) + is OnSSOLogin -> ssoLogin(action, routes) + OnShowImportMediaScreen -> importMedia(currentSessionId) + is OpenConversation -> openConversation(action, currentSessionId) + OnUnknownDeepLink -> unknownDeepLink(routes) + is ShowToast -> WireActivityNavigation3EffectResolution( + toastMessageResId = action.messageResId + ) + } + + private fun authorizationNeeded(routes: List) = + WireActivityNavigation3EffectResolution( + mutations = if (routes.startsWithEmptyWelcome()) { + listOf(navigate(newLoginRoute(), WireBackStackMode.CLEAR_WHOLE)) + } else { + emptyList() + }, + toastMessageResId = R.string.deeplink_authorization_needed, + ) + + private fun unknownDeepLink(routes: List) = + WireActivityNavigation3EffectResolution( + mutations = if (routes.startsWithEmptyWelcome()) { + listOf(navigate(newLoginRoute(), WireBackStackMode.CLEAR_WHOLE)) + } else { + emptyList() + } + ) + + private fun migration( + action: OnMigrationLogin, + routes: List, + canUseNewLogin: Boolean, + ): WireActivityNavigation3EffectResolution { + val args = AuthenticationLoginArguments( + userHandle = AuthenticationPrefilledUserIdentifier(action.result.userHandle) + ) + return if (canUseNewLogin) { + newLoginNavigation(args, routes) + } else { + singleNavigation( + LoginRoute(args = args, flowId = routes.legacyAuthenticationFlowId()), + routes.welcomeOrLoginReplacementMode(), + ) + } + } + + private fun automaticLogin( + action: OnAutomaticLogin, + routes: List, + ): WireActivityNavigation3EffectResolution { + val args = AuthenticationLoginArguments( + loginPasswordPath = AuthenticationLoginPasswordPath( + customServerConfig = action.serverLinks?.toAuthenticationServerLinks() + ), + ssoCodeAutoLogin = action.ssoCode?.let { + AuthenticationSsoCodeAutoLogin( + ssoCode = it, + autoInitiateLogin = true, + nomadServiceUrl = action.nomadServiceUrl, + cookieLabel = action.cookieLabel, + ) + }, + ) + return if (action.useNewLogin) { + newLoginNavigation(args, routes) + } else { + singleNavigation( + LoginRoute(args = args, flowId = routes.legacyAuthenticationFlowId()), + routes.welcomeOrLoginReplacementMode(), + ) + } + } + + private fun customBackendLogin( + action: OnCustomBackendLogin, + routes: List, + ): WireActivityNavigation3EffectResolution { + val serverLinks = action.serverLinks.toAuthenticationServerLinks() + val args = AuthenticationLoginArguments( + loginPasswordPath = AuthenticationLoginPasswordPath(customServerConfig = serverLinks), + showBackendConfigSuccess = true, + ) + return if (action.useNewLogin) { + newLoginNavigation(args, routes) + } else { + singleNavigation( + WelcomeRoute( + customServerConfig = serverLinks, + flowId = routes.legacyAuthenticationFlowId(), + ), + routes.welcomeOrLoginReplacementMode(), + ) + } + } + + private fun ssoLogin( + action: OnSSOLogin, + routes: List, + ): WireActivityNavigation3EffectResolution { + val typedResult = action.result.toAuthenticationSsoLoginResult() + val current = routes.lastOrNull() + return if (current is NewLoginRoute) { + WireActivityNavigation3EffectResolution( + mutations = listOf( + WireActivityNavigation3Mutation.ReplaceCurrent( + current.copy( + args = current.args.copy(ssoLoginResult = typedResult), + flowId = current.flowId, + entryId = current.entryId, + ) + ) + ) + ) + } else { + singleNavigation( + LoginRoute( + args = AuthenticationLoginArguments(ssoLoginResult = typedResult), + flowId = routes.legacyAuthenticationFlowId(), + ), + WireBackStackMode.UPDATE_EXISTING, + ) + } + } + + private fun openConversation( + action: OpenConversation, + currentSessionId: WireSessionId?, + ): WireActivityNavigation3EffectResolution { + val sessionId = action.result.targetSessionId?.let { + WireSessionId(it.value, it.domain) + } ?: requireSession(currentSessionId, action) + val mutations = buildList { + if (action.result.switchedAccount) { + add(navigate(HomeRoute(sessionId), WireBackStackMode.CLEAR_WHOLE)) + } + add( + navigate( + ConversationRoute( + sessionId = sessionId, + conversationId = ConversationRouteId( + action.result.conversationId.value, + action.result.conversationId.domain, + ), + ), + WireBackStackMode.UPDATE_EXISTING, + ) + ) + } + return WireActivityNavigation3EffectResolution(mutations) + } + + private fun openUserProfile( + action: OnOpenUserProfile, + currentSessionId: WireSessionId?, + ): WireActivityNavigation3EffectResolution { + val sessionId = action.result.targetSessionId?.let { + WireSessionId(it.value, it.domain) + } ?: requireSession(currentSessionId, action) + val mutations = buildList { + if (action.result.switchedAccount) { + add(navigate(HomeRoute(sessionId), WireBackStackMode.CLEAR_WHOLE)) + } + add( + navigate( + OtherUserProfileRoute( + sessionId = sessionId, + targetUserId = UserProfileQualifiedId( + action.result.userId.value, + action.result.userId.domain, + ), + ), + WireBackStackMode.UPDATE_EXISTING, + ) + ) + } + return WireActivityNavigation3EffectResolution(mutations) + } + + private fun importMedia(currentSessionId: WireSessionId?) = + singleNavigation( + destination = currentSessionId?.let(::AuthenticatedImportMediaRoute) + ?: LoggedOutImportMediaRoute(), + mode = WireBackStackMode.UPDATE_EXISTING, + ) + + private fun singleNavigation( + destination: WireRoute, + mode: WireBackStackMode, + ) = WireActivityNavigation3EffectResolution(listOf(navigate(destination, mode))) + + private fun newLoginNavigation( + args: AuthenticationLoginArguments, + routes: List, + ): WireActivityNavigation3EffectResolution { + val currentLoginRoot = routes.singleOrNull() as? NewLoginRoute + return if (currentLoginRoot != null) { + WireActivityNavigation3EffectResolution( + mutations = listOf( + WireActivityNavigation3Mutation.ReplaceCurrent( + currentLoginRoot.copy(args = args) + ) + ) + ) + } else { + singleNavigation(newLoginRoute(args), routes.welcomeOrLoginReplacementMode()) + } + } + + private fun navigate( + destination: WireRoute, + mode: WireBackStackMode, + ) = WireActivityNavigation3Mutation.Navigate( + WireNavigationCommand(destination = destination, backStackMode = mode) + ) + + private fun requireSession( + currentSessionId: WireSessionId?, + action: WireActivityViewAction, + ): WireSessionId = checkNotNull(currentSessionId) { + "${action::class.simpleName} requires an active session" + } + + private fun newLoginRoute( + args: AuthenticationLoginArguments = AuthenticationLoginArguments(), + ): NewLoginRoute = NewLoginRoute.start(args) + + private fun List.startsWithEmptyWelcome(): Boolean = + firstOrNull() is NewWelcomeEmptyStartRoute + + private fun List.welcomeOrLoginReplacementMode(): WireBackStackMode = + if (firstOrNull().isAuthenticationStartRoute()) { + WireBackStackMode.CLEAR_WHOLE + } else { + WireBackStackMode.UPDATE_EXISTING + } + + private fun List.legacyAuthenticationFlowId(): String = when (val route = lastOrNull()) { + is WelcomeRoute -> route.flowId + is LoginRoute -> route.flowId + else -> WireNavEntryId.random().value + } + + private fun WireRoute?.isAuthenticationStartRoute(): Boolean = + this is NewWelcomeEmptyStartRoute || + this is WelcomeRoute || + this is NewLoginRoute || + this is LoginRoute +} + +private fun showNavigation3Toast(context: Context, messageResId: Int) { + Toast.makeText( + context, + context.resources.getString(messageResId), + Toast.LENGTH_SHORT, + ).show() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt new file mode 100644 index 00000000000..8f73f78d038 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt @@ -0,0 +1,593 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.lifecycle.ViewModelStoreOwner +import com.wire.android.config.CustomUiConfigurationProvider +import com.wire.android.config.LocalCustomUiConfigurationProvider +import com.wire.android.datastore.UserDataStore +import com.wire.android.di.metro.AppAuthenticationViewModelGraph +import com.wire.android.di.metro.WireApplicationGraph +import com.wire.android.di.metro.WireViewModelDiagnostics +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.navigation.LoginTypeSelector +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import com.wire.android.navigation.navigation3.clearWireViewModelStoreOwner +import com.wire.android.navigation.navigation3.rememberWireSharedViewModelStoreOwner +import com.wire.android.navigation.navigation3.rememberWireNavigation3Runtime +import com.wire.android.navigation.navigation3.rememberWireViewModelStoreProvider +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.navigation.routes.auth.NewLoginPasswordRoute +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.auth.toAuthenticationServerLinks +import com.wire.android.navigation.runtime.MetroWireEntryEnvironment +import com.wire.android.navigation.runtime.MetroWireEntryGraphResolver +import com.wire.android.navigation.runtime.ProvideViewModelGraph +import com.wire.android.navigation.runtime.SessionGraphStoreViewModel +import com.wire.android.navigation.runtime.wireSessionGraphStoreViewModel +import com.wire.android.navigation.runtime.WireActivityGraphContext +import com.wire.android.navigation.runtime.WireActivityIntentCoordinator +import com.wire.android.navigation.runtime.WireActivityIntentRequest +import com.wire.android.navigation.runtime.WireNavigation3ActivityCallbacks +import com.wire.android.navigation.runtime.WireNavigation3ActivityEffects +import com.wire.android.navigation.runtime.WireNavigation3ActivityPolicy +import com.wire.android.navigation.runtime.WireNavigation3Contributions +import com.wire.android.navigation.runtime.WireNavigation3ProductionActions +import com.wire.android.navigation.runtime.WireNavigation3ProductionHost +import com.wire.android.navigation.runtime.WireNavigation3SwitchAccountActions +import com.wire.android.navigation.runtime.WireNavigationDiagnostics +import com.wire.android.navigation.runtime.noOtherAccountNavigationCommand +import com.wire.android.navigation.runtime.rememberWireNavigation3ActivityGraphContext +import com.wire.android.navigation.runtime.rememberWireNavigation3ProductionActions +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.navigation.runtime.startup.toWireSessionId +import com.wire.android.navigation.runtime.toKaliumUserId +import com.wire.android.navigation.style.BackgroundType +import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.common.topappbar.CommonTopAppBarState +import com.wire.android.ui.common.topappbar.WireTopAppBar +import com.wire.android.ui.theme.ThemeOption +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.CurrentScreenManager +import com.wire.android.util.LocalSyncStateObserver +import com.wire.android.util.SwitchAccountObserver +import com.wire.android.util.SyncStateObserver +import com.wire.android.util.debug.FeatureVisibilityFlags +import com.wire.android.util.debug.LocalFeatureVisibilityFlags +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner +import com.wire.navigation.stableKey +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.Json + +internal data class WireActivityHostDependencies( + val activity: AppCompatActivity, + val viewModel: WireActivityViewModel, + val appGraph: WireApplicationGraph, + val loginTypeSelector: LoginTypeSelector, + val intentCoordinator: WireActivityIntentCoordinator, + val currentScreenManager: CurrentScreenManager, + val switchAccountObserver: SwitchAccountObserver, + val shakeEvents: Flow, + val onIntentRequest: suspend ( + WireNavigation3Runtime, + AuthenticationNavigation3Router, + WireActivityIntentRequest, + ) -> Unit, + val onShake: (WireNavigation3Runtime) -> Unit, + val onStartTeamAppLock: () -> Unit, + val onUpdateApp: () -> Unit, + val onScreenshotCensoringChanged: (Boolean) -> Unit, +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun WireActivityNavigation3Host( + startDestination: WireRoute, + dependencies: WireActivityHostDependencies, +) { + val snackbarHostState = remember { SnackbarHostState() } + val authenticationViewModelGraph = remember(dependencies.appGraph) { + dependencies.appGraph.authenticationViewModelGraph + } + val sessionGraphStore = wireSessionGraphStoreViewModel( + appGraph = dependencies.appGraph, + owner = dependencies.activity, + ) + val sharedViewModelStoreProvider = rememberWireViewModelStoreProvider() + + CompositionLocalProvider( + LocalMetroViewModelFactory provides dependencies.appGraph.metroViewModelFactory, + LocalFeatureVisibilityFlags provides FeatureVisibilityFlags, + LocalSyncStateObserver provides SyncStateObserver(dependencies.viewModel.observeSyncFlowState), + LocalCustomUiConfigurationProvider provides CustomUiConfigurationProvider, + LocalSnackbarHostState provides snackbarHostState, + LocalActivity provides dependencies.activity, + ) { + HandleWireActivityThemeChanges(dependencies.viewModel.globalAppState.themeOption) + WireTheme(accent = dependencies.viewModel.globalAppState.userAccent) { + WireActivityNavigation3ThemedHost( + startDestination = startDestination, + dependencies = dependencies, + authenticationViewModelGraph = authenticationViewModelGraph, + sessionGraphStore = sessionGraphStore, + sharedViewModelStoreProvider = sharedViewModelStoreProvider, + ) + } + } +} + +@Composable +private fun WireActivityNavigation3ThemedHost( + startDestination: WireRoute, + dependencies: WireActivityHostDependencies, + authenticationViewModelGraph: AppAuthenticationViewModelGraph, + sessionGraphStore: SessionGraphStoreViewModel, + sharedViewModelStoreProvider: ViewModelStoreProvider, +) { + val context = LocalContext.current + val viewModel = dependencies.viewModel + val isUserUiBlocked = viewModel.globalAppState.blockUserUI != null + val currentSessionProvider: () -> WireSessionId? = remember { + { + viewModel.globalAppState.currentUserId?.toWireSessionId() + } + } + val runtime = rememberWireNavigation3Runtime( + startRoute = startDestination, + resultTypes = WireNavigation3Contributions.resultTypes(), + canNavigate = { command -> + command.destination !is NewLoginRoute || viewModel.checkNumberOfSessions() + }, + onNavigationChanged = WireNavigationDiagnostics::navigation, + ) + val authenticationRouter = remember(runtime) { + AuthenticationNavigation3Router(runtime) + } + val currentRoute = runtime.navigator.currentRoute + val currentUserId = viewModel.globalAppState.currentUserId + val isSessionTransitionInProgress = viewModel.globalAppState.isSessionTransitionInProgress + val sessionTransitionReason = viewModel.globalAppState.sessionTransitionReason + val shouldInvalidateRetainedSessionGraph = shouldInvalidateWireActivitySessionGraph( + isUserUiBlocked = isUserUiBlocked, + sessionTransitionReason = sessionTransitionReason, + ) + val currentRouteSessionId = (currentRoute as? com.wire.navigation.SessionRoute)?.sessionId + val appliedSessionGeneration = rememberConfirmedSessionGraphAvailability( + confirmedSessionGeneration = viewModel.globalAppState.confirmedSessionGeneration, + currentUserId = currentUserId, + currentRouteSessionId = currentRouteSessionId, + invalidated = shouldInvalidateRetainedSessionGraph, + sessionGraphStore = sessionGraphStore, + ) + val graphResolver = remember( + dependencies.appGraph, + authenticationViewModelGraph, + sessionGraphStore, + appliedSessionGeneration, + ) { + MetroWireEntryGraphResolver( + appGraph = dependencies.appGraph, + authenticationGraph = authenticationViewModelGraph, + sessionGraphStore = sessionGraphStore, + ) + } + val graphContext = rememberWireNavigation3ActivityGraphContext( + route = currentRoute, + graphResolver = graphResolver, + isUserUiBlocked = isUserUiBlocked, + ) + val sessionLifecycle = rememberWireActivitySessionLifecycle( + routeSessionId = currentRouteSessionId, + graphContext = graphContext, + invalidated = shouldInvalidateRetainedSessionGraph, + sessionGraphStore = sessionGraphStore, + provider = sharedViewModelStoreProvider, + ) + val rootSwitchActions = remember(runtime, dependencies.loginTypeSelector) { + WireNavigation3SwitchAccountActions( + onSwitchedToAnotherAccount = { + currentSessionProvider()?.let { + authenticationRouter.navigate( + AuthenticationNavigationTransition.ACCOUNT_SWITCH_TO_HOME, + HomeRoute(it), + WireBackStackMode.CLEAR_WHOLE, + ) + } + }, + onNoOtherAccountToSwitch = { + noOtherAccountNavigationCommand( + currentRoute = runtime.navigator.currentRoute, + useNewLogin = dependencies.loginTypeSelector.canUseNewLogin(), + recoveryServerLinks = dependencies.viewModel + .consumeSessionRecoveryServerLinks() + ?.toAuthenticationServerLinks(), + )?.let { + authenticationRouter.navigate( + AuthenticationNavigationTransition.ACCOUNT_SWITCH_TO_LOGIN, + it, + ) + } + }, + ) + } + val activityEffects = remember(dependencies.activity) { + WireNavigation3ActivityEffects(dependencies.activity) + } + var pendingTeamAccountReturnRoute by rememberSaveable { mutableStateOf(null) } + val teamAccountLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + pendingTeamAccountReturnRoute?.let { encodedReturnRoute -> + authenticationRouter.navigate( + AuthenticationNavigationTransition.TEAM_WEB_FLOW_TO_PASSWORD, + Json.decodeFromString(encodedReturnRoute), + WireBackStackMode.UPDATE_EXISTING, + ) + } + pendingTeamAccountReturnRoute = null + } + val activityCallbacks = remember(runtime, activityEffects, teamAccountLauncher) { + WireNavigation3ActivityCallbacks( + finish = dependencies.activity::finish, + openUrl = activityEffects::openUrl, + openIntent = activityEffects::openExternal, + openTeamAccountWebFlow = { request -> + activityEffects.teamAccountWebLaunch(request).let { launch -> + pendingTeamAccountReturnRoute = Json.encodeToString(launch.returnRoute) + teamAccountLauncher.launch(launch.intent) + } + }, + completeAppLock = { + if (!runtime.navigator.goBack()) dependencies.activity.finish() + }, + cancelAppLock = { + if (!runtime.navigator.goBack()) dependencies.activity.finish() + }, + hardLogout = { + viewModel.doHardLogout( + clearUserData = { userId -> UserDataStore(context, userId) }, + switchAccountActions = rootSwitchActions, + ) + }, + restartAfterLogout = activityEffects::restartAfterLogout, + moveTaskToBackground = { dependencies.activity.moveTaskToBack(true) }, + ) + } + val actions = rememberWireNavigation3ProductionActions( + runtime = runtime, + activity = activityCallbacks, + currentSessionId = currentSessionProvider, + loginTypeSelector = dependencies.loginTypeSelector, + authenticationRouter = authenticationRouter, + ) + val entryEnvironment = remember( + dependencies.appGraph, + authenticationViewModelGraph, + sessionGraphStore, + rootSwitchActions, + ) { + MetroWireEntryEnvironment( + appGraph = dependencies.appGraph, + authenticationGraph = authenticationViewModelGraph, + sessionGraphStore = sessionGraphStore, + logoutAction = { wipeData -> + viewModel.doHardLogout( + clearUserData = { userId -> UserDataStore(context, userId) }, + switchAccountActions = rootSwitchActions, + wipeData = wipeData, + ) + }, + ) + } + val backgroundType = WireNavigation3ActivityPolicy.backgroundType(currentRoute) + + HandleNavigation3SessionEffects( + runtime = runtime, + authenticationRouter = authenticationRouter, + currentUserId = currentSessionProvider(), + isUserUiBlocked = isUserUiBlocked, + isSessionTransitionInProgress = isSessionTransitionInProgress, + isSelfLogoutTransition = + viewModel.globalAppState.sessionTransitionReason == SessionTransitionReason.SELF_LOGOUT, + finishSessionTransition = viewModel::finishSessionTransition, + resolveMissingCurrentSession = { + viewModel.resolveMissingCurrentSession(rootSwitchActions) + }, + ) + WireActivityNavigation3MainContent( + runtime = runtime, + actions = actions, + authenticationRouter = authenticationRouter, + entryEnvironment = entryEnvironment, + graphContext = sessionLifecycle.contentGraphContext, + backgroundType = backgroundType, + dependencies = dependencies, + switchAccountActions = rootSwitchActions, + activitySessionViewModelStoreOwner = sessionLifecycle.sessionViewModelStoreOwner, + sharedViewModelStoreProvider = sharedViewModelStoreProvider, + ) +} + +@Composable +private fun rememberConfirmedSessionGraphAvailability( + confirmedSessionGeneration: Long, + currentUserId: UserId?, + currentRouteSessionId: WireSessionId?, + invalidated: Boolean, + sessionGraphStore: SessionGraphStoreViewModel, +): Long { + var appliedGeneration by remember(sessionGraphStore) { mutableLongStateOf(-1L) } + LaunchedEffect( + confirmedSessionGeneration, + currentUserId, + currentRouteSessionId, + invalidated, + sessionGraphStore, + ) { + val generationCanBeApplied = confirmedSessionGeneration > appliedGeneration && !invalidated + val confirmedUserId = currentUserId?.takeIf { + it.toWireSessionId() == currentRouteSessionId + } + if (generationCanBeApplied && confirmedUserId != null) { + sessionGraphStore.markAvailable(confirmedUserId) + appliedGeneration = confirmedSessionGeneration + } + } + return appliedGeneration +} + +private data class WireActivitySessionLifecycle( + val contentGraphContext: WireActivityGraphContext?, + val sessionViewModelStoreOwner: ViewModelStoreOwner?, +) + +@Composable +private fun rememberWireActivitySessionLifecycle( + routeSessionId: WireSessionId?, + graphContext: WireActivityGraphContext?, + invalidated: Boolean, + sessionGraphStore: SessionGraphStoreViewModel, + provider: ViewModelStoreProvider, +): WireActivitySessionLifecycle { + var retainedRouteSessionId by remember { mutableStateOf(null) } + val activeSessionId = graphContext + ?.takeIf { it.sessionGraph != null } + ?.sessionId + val sessionOwnerIdentity = activeSessionId?.let(WireViewModelOwner::Session) + val teardownSessionId = routeSessionId ?: retainedRouteSessionId + + SideEffect { + if (!invalidated && activeSessionId != null) { + retainedRouteSessionId = activeSessionId + } + } + + LaunchedEffect(invalidated, teardownSessionId) { + if (invalidated && teardownSessionId != null) { + // The successful composition above has already stopped rendering the session host and + // disposed its entry effects. The remaining teardown is deliberately serialized: + // shared ViewModels first, then the Metro graph. A tombstone rejects stale entries. + teardownWireActivitySession( + sessionId = teardownSessionId, + markInvalidating = sessionGraphStore::markInvalidating, + clearOwner = { ownerKey -> + clearWireViewModelStoreOwner(provider, ownerKey) { + WireViewModelDiagnostics.ownerCleared(ownerKey) + } + }, + markRemoved = sessionGraphStore::markRemoved, + ) + retainedRouteSessionId = null + } + } + + val owner = sessionOwnerIdentity + ?.takeUnless { invalidated } + ?.let { + rememberWireSharedViewModelStoreOwner( + key = it.stableKey(), + provider = provider, + ) + } + if (sessionOwnerIdentity != null && owner != null) { + DisposableEffect(sessionOwnerIdentity, owner) { + val ownerKey = sessionOwnerIdentity.stableKey() + WireViewModelDiagnostics.ownerAvailable(owner, ownerKey) + onDispose { + WireViewModelDiagnostics.ownerReleased(owner, ownerKey) + } + } + } + val contentGraphContext = graphContext?.takeUnless { invalidated } + return WireActivitySessionLifecycle(contentGraphContext, owner) +} + +internal fun teardownWireActivitySession( + sessionId: WireSessionId, + markInvalidating: (UserId) -> Unit, + clearOwner: (String) -> Unit, + markRemoved: (UserId) -> Unit, +) { + val userId = sessionId.toKaliumUserId() + markInvalidating(userId) + clearOwner(WireViewModelOwner.Session(sessionId).stableKey()) + markRemoved(userId) +} + +internal fun shouldInvalidateWireActivitySessionGraph( + isUserUiBlocked: Boolean, + sessionTransitionReason: SessionTransitionReason?, +): Boolean = isUserUiBlocked || sessionTransitionReason != null + +@Composable +private fun WireActivityNavigation3MainContent( + runtime: WireNavigation3Runtime, + actions: WireNavigation3ProductionActions, + authenticationRouter: AuthenticationNavigation3Router, + entryEnvironment: MetroWireEntryEnvironment, + graphContext: WireActivityGraphContext?, + backgroundType: BackgroundType, + dependencies: WireActivityHostDependencies, + switchAccountActions: SwitchAccountActions, + activitySessionViewModelStoreOwner: ViewModelStoreOwner?, + sharedViewModelStoreProvider: ViewModelStoreProvider, +) { + val viewModel = dependencies.viewModel + val context = LocalContext.current + val dialogActions = navigation3DialogActions( + runtime = runtime, + switchAccountActions = switchAccountActions, + dependencies = WireActivityDialogActionDependencies( + context = context, + viewModel = viewModel, + updateApp = dependencies.onUpdateApp, + startTeamAppLock = dependencies.onStartTeamAppLock, + ), + ) + if (backgroundType == BackgroundType.Auth) { + WireAuthBackgroundLayout() + } + if (graphContext == null) { + WireActivityDialogs( + viewModel = viewModel, + activityViewModels = null, + actions = dialogActions, + ) + return + } + graphContext.ProvideViewModelGraph( + logoutAction = { wipeData -> + viewModel.doHardLogout( + clearUserData = { userId -> UserDataStore(context, userId) }, + switchAccountActions = switchAccountActions, + wipeData = wipeData, + ) + } + ) { + val activityViewModels = if ( + graphContext.sessionGraph != null && activitySessionViewModelStoreOwner != null + ) { + wireActivityScopedViewModels(activitySessionViewModelStoreOwner) + } else { + null + } + activityViewModels?.let { + LaunchedEffect(it.legalHoldRequestedViewModel) { + it.legalHoldRequestedViewModel.observeLegalHoldRequest() + } + } + Column( + modifier = Modifier.semantics { testTagsAsResourceId = true } + ) { + WireTopAppBar( + commonTopAppBarState = + activityViewModels?.commonTopAppBarViewModel?.state ?: CommonTopAppBarState(), + backgroundType = backgroundType, + ) + WireNavigation3ProductionHost( + runtime = runtime, + actions = actions, + authenticationRouter = authenticationRouter, + entryEnvironment = entryEnvironment, + onRootBack = dependencies.activity::finish, + sharedViewModelStoreProvider = sharedViewModelStoreProvider, + modifier = Modifier.consumeWindowInsets(WindowInsets.statusBars), + ) + WireActivityNavigation3Setup( + runtime = runtime, + intentCoordinator = dependencies.intentCoordinator, + currentScreenManager = dependencies.currentScreenManager, + switchAccountObserver = dependencies.switchAccountObserver, + switchAccountActions = switchAccountActions, + shakeEvents = dependencies.shakeEvents, + onIntentRequest = { + dependencies.onIntentRequest(runtime, authenticationRouter, it) + }, + onShake = { dependencies.onShake(runtime) }, + ) + HandleWireActivityScreenshotCensoring(dependencies) + WireActivityDialogs( + viewModel = viewModel, + activityViewModels = activityViewModels, + actions = dialogActions, + ) + HandleNavigation3ViewActions( + actions = viewModel.actions, + runtime = runtime, + authenticationRouter = authenticationRouter, + loginTypeSelector = dependencies.loginTypeSelector, + currentSessionId = { + viewModel.globalAppState.currentUserId?.toWireSessionId() + }, + ) + } + } +} + +@Composable +private fun HandleWireActivityThemeChanges(themeOption: ThemeOption) { + LaunchedEffect(themeOption) { + val themeNightMode = when (themeOption) { + ThemeOption.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ThemeOption.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + ThemeOption.DARK -> AppCompatDelegate.MODE_NIGHT_YES + } + if (themeNightMode != AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.setDefaultNightMode(themeNightMode) + } + } +} + +@Composable +private fun HandleWireActivityScreenshotCensoring( + dependencies: WireActivityHostDependencies, +) { + val screenshotCensoringEnabled = + dependencies.viewModel.globalAppState.screenshotCensoringEnabled + LaunchedEffect(screenshotCensoringEnabled) { + dependencies.onScreenshotCensoringChanged(screenshotCensoringEnabled) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffects.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffects.kt new file mode 100644 index 00000000000..755f88a2f06 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffects.kt @@ -0,0 +1,151 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.wire.android.appLogger +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.runtime.WireNavigation3ActivityPolicy +import com.wire.android.navigation.runtime.WireSessionNavigationDecision +import com.wire.android.navigation.runtime.WireSessionNavigationPolicy +import com.wire.android.navigation.runtime.WireSessionNavigationSnapshot +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId + +/** + * Activity-level session policy for the Navigation 3 host. + * + * The current route owns its Metro session through [SessionRoute]. The globally selected account + * remains an application policy input only; it no longer determines a NavHost or ViewModel graph + * key and therefore cannot tear down navigation during a transient account switch. + */ +@Composable +internal fun HandleNavigation3SessionEffects( + runtime: WireNavigation3Runtime, + authenticationRouter: AuthenticationNavigation3Router, + currentUserId: WireSessionId?, + isUserUiBlocked: Boolean, + isSessionTransitionInProgress: Boolean, + isSelfLogoutTransition: Boolean, + finishSessionTransition: () -> Unit, + resolveMissingCurrentSession: () -> Unit, +) { + val currentRoute = runtime.navigator.currentRoute + + LaunchedEffect( + currentRoute, + currentUserId, + isSessionTransitionInProgress, + isUserUiBlocked, + isSelfLogoutTransition, + ) { + val snapshot = WireNavigation3ActivityPolicy.sessionSnapshot( + route = currentRoute, + hasCurrentSession = currentUserId != null, + isUserUiBlocked = isUserUiBlocked, + isSessionTransitionInProgress = isSessionTransitionInProgress, + isSelfLogoutTransition = isSelfLogoutTransition, + ) + appLogger.i( + "WireActivity graph route=${currentRoute?.routeId} " + + "routeSession=${(currentRoute as? SessionRoute)?.sessionId} " + + "currentSession=$currentUserId" + ) + when ( + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot, + currentSessionId = currentUserId, + routeSessionId = (currentRoute as? SessionRoute)?.sessionId, + ) + ) { + WireActivityNavigation3SessionEffect.FinishTransition -> + finishSessionTransition() + + WireActivityNavigation3SessionEffect.WaitForBlockingDialog -> + appLogger.i("WireActivity blocking session dialog visible; waiting for user action") + + is WireActivityNavigation3SessionEffect.Navigate -> + authenticationRouter.navigate( + AuthenticationNavigationTransition.SESSION_POLICY, + effect.command, + ) + + WireActivityNavigation3SessionEffect.ResolveCurrentSession -> + resolveMissingCurrentSession() + + WireActivityNavigation3SessionEffect.None -> Unit + } + } +} + +internal sealed interface WireActivityNavigation3SessionEffect { + data object FinishTransition : WireActivityNavigation3SessionEffect + data object WaitForBlockingDialog : WireActivityNavigation3SessionEffect + data class Navigate(val command: WireNavigationCommand) : WireActivityNavigation3SessionEffect + data object ResolveCurrentSession : WireActivityNavigation3SessionEffect + data object None : WireActivityNavigation3SessionEffect +} + +internal object WireActivityNavigation3SessionEffectResolver { + fun resolve( + snapshot: WireSessionNavigationSnapshot, + currentSessionId: WireSessionId?, + routeSessionId: WireSessionId? = null, + ): WireActivityNavigation3SessionEffect { + if (snapshot.isSessionTransitionInProgress && snapshot.isAuthenticationRoute) { + return WireActivityNavigation3SessionEffect.FinishTransition + } + return when (WireSessionNavigationPolicy.resolve(snapshot)) { + WireSessionNavigationDecision.WAIT_FOR_BLOCKING_DIALOG -> + WireActivityNavigation3SessionEffect.WaitForBlockingDialog + + WireSessionNavigationDecision.NAVIGATE_HOME_CLEAR_STACK -> + currentSessionId?.let { + navigate(HomeRoute(it)) + } ?: WireActivityNavigation3SessionEffect.None + + WireSessionNavigationDecision.NAVIGATE_LOGIN_CLEAR_STACK -> + navigate(NewLoginRoute.start()) + + WireSessionNavigationDecision.RESOLVE_CURRENT_SESSION -> + WireActivityNavigation3SessionEffect.ResolveCurrentSession + + WireSessionNavigationDecision.NONE -> + if (hasMismatchedOrdinarySession(snapshot, currentSessionId, routeSessionId)) { + navigate(HomeRoute(checkNotNull(currentSessionId))) + } else { + WireActivityNavigation3SessionEffect.None + } + } + } + + private fun hasMismatchedOrdinarySession( + snapshot: WireSessionNavigationSnapshot, + currentSessionId: WireSessionId?, + routeSessionId: WireSessionId?, + ): Boolean = currentSessionId != null && + routeSessionId != null && + currentSessionId != routeSessionId && + !snapshot.isSessionBackedAuthenticationRoute + + private fun navigate(route: WireRoute) = + WireActivityNavigation3SessionEffect.Navigate( + WireNavigationCommand(route, WireBackStackMode.CLEAR_WHOLE) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Setup.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Setup.kt new file mode 100644 index 00000000000..ac8cf1ce8ba --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Setup.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.runtime.ObserveNavigation3Routes +import com.wire.android.navigation.runtime.WireActivityIntentCoordinator +import com.wire.android.navigation.runtime.WireActivityIntentRequest +import com.wire.android.util.CurrentScreenManager +import com.wire.android.util.SwitchAccountObserver +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest + +/** + * Owns the Activity-level observers that depend on the typed Navigation 3 runtime. + * + * Keeping these effects together gives [WireActivity] one setup call and prevents the Activity + * from depending on a Nav2 controller or generated destination types. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun WireActivityNavigation3Setup( + runtime: WireNavigation3Runtime, + intentCoordinator: WireActivityIntentCoordinator, + currentScreenManager: CurrentScreenManager, + switchAccountObserver: SwitchAccountObserver, + switchAccountActions: SwitchAccountActions, + shakeEvents: Flow, + onIntentRequest: suspend (WireActivityIntentRequest) -> Unit, + onShake: suspend () -> Unit, + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val currentKeyboardController by rememberUpdatedState(keyboardController) + val currentIntentHandler by rememberUpdatedState(onIntentRequest) + val currentShakeHandler by rememberUpdatedState(onShake) + + ObserveNavigation3Routes( + runtime = runtime, + currentScreenManager = currentScreenManager, + onRouteChanged = { currentKeyboardController?.hide() }, + ) + + LaunchedEffect(intentCoordinator, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + intentCoordinator.requests.collectLatest { request -> + currentKeyboardController?.hide() + currentIntentHandler(request) + } + } + } + + DisposableEffect(switchAccountObserver, switchAccountActions) { + val unregister = registerWireActivityNavigation3SwitchAccountActions( + observer = switchAccountObserver, + actions = switchAccountActions, + ) + onDispose(unregister) + } + + LaunchedEffect(shakeEvents, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + shakeEvents.collectLatest { + currentShakeHandler() + } + } + } +} + +internal fun registerWireActivityNavigation3SwitchAccountActions( + observer: SwitchAccountObserver, + actions: SwitchAccountActions, +): () -> Unit { + observer.register(actions) + return { observer.unregister(actions) } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityScopedViewModels.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityScopedViewModels.kt new file mode 100644 index 00000000000..65576007fff --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityScopedViewModels.kt @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModelStoreOwner +import com.wire.android.di.metro.wireMetroViewModel +import com.wire.android.ui.common.commonTopAppBarViewModel +import com.wire.android.ui.common.topappbar.CommonTopAppBarParams +import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel +import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel +import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateViewModel +import com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel +import com.wire.android.ui.legalhold.dialog.deactivated.LegalHoldDeactivatedViewModel +import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel + +/** + * Session-owned activity chrome dependencies. + * + * The current Metro graph and Navigation 3 owner are supplied by composition. Keeping the + * ownership policy here removes graph-derived ViewModel keys and concrete factory calls from + * [WireActivity], while the explicit owner gives all chrome dependencies one session lifetime. + */ +internal data class WireActivityScopedViewModels( + val callFeedbackViewModel: CallFeedbackViewModel, + val featureFlagNotificationViewModel: FeatureFlagNotificationViewModel, + val commonTopAppBarViewModel: CommonTopAppBarViewModel, + val legalHoldRequestedViewModel: LegalHoldRequestedViewModel, + val legalHoldDeactivatedViewModel: LegalHoldDeactivatedViewModel, + val getE2EICertificateViewModel: GetE2EICertificateViewModel, + val joinConversationViaCodeViewModel: JoinConversationViaCodeViewModel, +) + +@Composable +internal fun wireActivityScopedViewModels( + owner: ViewModelStoreOwner, +): WireActivityScopedViewModels = + WireActivityScopedViewModels( + callFeedbackViewModel = wireMetroViewModel(owner = owner), + featureFlagNotificationViewModel = wireMetroViewModel(owner = owner), + commonTopAppBarViewModel = commonTopAppBarViewModel( + CommonTopAppBarParams( + showNoNetwork = true, + showSync = true, + showActiveCalls = true, + ), + owner = owner, + ), + legalHoldRequestedViewModel = wireMetroViewModel(owner = owner), + legalHoldDeactivatedViewModel = wireMetroViewModel(owner = owner), + getE2EICertificateViewModel = wireMetroViewModel(owner = owner), + joinConversationViaCodeViewModel = wireMetroViewModel(owner = owner), + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index d908c79c2d2..6faf94d51d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -179,14 +179,6 @@ class WireActivityViewModel @Inject constructor( .flowOn(dispatchers.io()) .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) - private val observeCurrentUserId: SharedFlow = observeCurrentAccountInfo - .map { - if (it?.isValid() == true) it.userId else null - } - .distinctUntilChanged() - .flowOn(dispatchers.io()) - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) - private lateinit var validSessions: StateFlow> init { @@ -273,20 +265,8 @@ class WireActivityViewModel @Inject constructor( private fun observeCurrentUserState() { viewModelScope.launch(dispatchers.io()) { - observeCurrentUserId.collectLatest { - globalAppState = globalAppState.copy( - currentUserId = it, - isSessionTransitionInProgress = if (it != null) { - false - } else { - globalAppState.isSessionTransitionInProgress - }, - sessionTransitionReason = if (it != null) { - null - } else { - globalAppState.sessionTransitionReason - }, - ) + observeCurrentAccountInfo.collectLatest { accountInfo -> + globalAppState = globalAppState.withObservedCurrentAccount(accountInfo) } } } @@ -296,9 +276,12 @@ class WireActivityViewModel @Inject constructor( observeCurrentAccountInfo .collect { when (it) { - is AccountInfo.Invalid -> handleInvalidSession(it.logoutReason) + is AccountInfo.Invalid -> handleInvalidSession(it.userId, it.logoutReason) is AccountInfo.Valid -> withContext(dispatchers.main()) { - globalAppState = globalAppState.copy(blockUserUI = null) + globalAppState = globalAppState.copy( + blockUserUI = null, + pendingRemovedClientRecovery = null, + ) } null -> {} @@ -380,13 +363,13 @@ class WireActivityViewModel @Inject constructor( } } - private suspend fun handleInvalidSession(logoutReason: LogoutReason) { + private suspend fun handleInvalidSession(userId: UserId, logoutReason: LogoutReason) { when (logoutReason) { LogoutReason.SELF_SOFT_LOGOUT, LogoutReason.SELF_HARD_LOGOUT -> { // Self logout is handled from the Self user profile screen directly. } - LogoutReason.REMOVED_CLIENT -> recoverFromRemovedClient() + LogoutReason.REMOVED_CLIENT -> recoverFromRemovedClient(userId) LogoutReason.MIGRATION_TO_CC_FAILED -> setCurrentSessionError(CurrentSessionErrorState.RemovedClient) @@ -399,17 +382,31 @@ class WireActivityViewModel @Inject constructor( } } - private suspend fun recoverFromRemovedClient() { + private suspend fun recoverFromRemovedClient(userId: UserId) { + val serverLinks = runCatching { + when (val result = coreLogic.value.getSessionScope(userId).users.serverLinks()) { + is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links + is SelfServerConfigUseCase.Result.Failure -> null + } + }.getOrNull() withContext(dispatchers.main()) { globalAppState = globalAppState.copy( currentUserId = null, blockUserUI = CurrentSessionErrorState.RemovedClient, isSessionTransitionInProgress = false, sessionTransitionReason = SessionTransitionReason.REMOVED_CLIENT, + pendingRemovedClientRecovery = serverLinks?.let { + PendingRemovedClientRecovery(userId, it) + }, ) } } + fun consumeSessionRecoveryServerLinks(): ServerConfig.Links? = + globalAppState.pendingRemovedClientRecovery?.serverLinks.also { + globalAppState = globalAppState.copy(pendingRemovedClientRecovery = null) + } + private suspend fun setCurrentSessionError(errorState: CurrentSessionErrorState) { withContext(dispatchers.main()) { globalAppState = globalAppState.copy( @@ -471,7 +468,15 @@ class WireActivityViewModel @Inject constructor( result.key, result.domain ) { conversationId -> - sendAction(OpenConversation(DeepLinkResult.OpenConversation(conversationId, result.switchedAccount))) + sendAction( + OpenConversation( + DeepLinkResult.OpenConversation( + conversationId = conversationId, + switchedAccount = result.switchedAccount, + targetSessionId = result.targetSessionId, + ) + ) + ) } is DeepLinkResult.MigrationLogin -> sendAction(OnMigrationLogin(result)) @@ -684,13 +689,13 @@ class WireActivityViewModel @Inject constructor( } } - fun tryToSwitchAccount() { + fun tryToSwitchAccount(actions: SwitchAccountActions) { viewModelScope.launch { - accountSwitch.value.invoke(SwitchAccountParam.TryToSwitchToNextAccount) + globalAppState = globalAppState.copy(isSessionTransitionInProgress = true) + val result = accountSwitch.value.invoke(SwitchAccountParam.TryToSwitchToNextAccount) syncCurrentUserIdFromSession() - // Clearing the dialog recreates the NavHost from the synchronized session state. - // WireActivity then performs any required navigation after the graph is installed. globalAppState = globalAppState.copy(blockUserUI = null) + result.callAction(actions) } } @@ -910,12 +915,11 @@ class WireActivityViewModel @Inject constructor( } private fun onOpenUserProfileDeepLink(result: DeepLinkResult.OpenOtherUserProfile) = viewModelScope.launch { - observeCurrentValidUserId.first()?.let { userId -> - if (isProfileQRCodeEnabledFactory.create(userId).isProfileQRCodeEnabled()) { - sendAction(OnOpenUserProfile(result)) - } else { - sendAction(ShowToast(R.string.profile_deeplink_feature_unavailable_title_alert)) - } + val userId = result.targetSessionId ?: observeCurrentValidUserId.first() ?: return@launch + if (isProfileQRCodeEnabledFactory.create(userId).isProfileQRCodeEnabled()) { + sendAction(OnOpenUserProfile(result)) + } else { + sendAction(ShowToast(R.string.profile_deeplink_feature_unavailable_title_alert)) } } @@ -993,6 +997,7 @@ data class NewClientInfo(val date: Instant?, val deviceInfo: UIText) { data class GlobalAppState( val currentUserId: UserId? = null, + val confirmedSessionGeneration: Long = 0, val customBackendDialog: CustomServerDialogState? = null, val maxAccountDialog: Boolean = false, val crossBackendLoginBlockedDialog: Boolean = false, @@ -1005,8 +1010,40 @@ data class GlobalAppState( val userAccent: Accent = Accent.Unknown, val isSessionTransitionInProgress: Boolean = false, val sessionTransitionReason: SessionTransitionReason? = null, + val pendingRemovedClientRecovery: PendingRemovedClientRecovery? = null, +) + +data class PendingRemovedClientRecovery( + val userId: UserId, + val serverLinks: ServerConfig.Links, ) +/** + * Invalid accounts are deliberately ignored here. [handleInvalidSession] publishes the missing + * session and its blocking dialog in one state update, preventing session navigation from seeing + * a transient logged-out state and racing the dialog to the login route. + */ +internal fun GlobalAppState.withObservedCurrentAccount(accountInfo: AccountInfo?): GlobalAppState = + when (accountInfo) { + is AccountInfo.Valid -> copy( + currentUserId = accountInfo.userId, + confirmedSessionGeneration = if ( + currentUserId != accountInfo.userId || + sessionTransitionReason != null || + blockUserUI != null + ) { + confirmedSessionGeneration + 1 + } else { + confirmedSessionGeneration + }, + isSessionTransitionInProgress = false, + sessionTransitionReason = null, + ) + + is AccountInfo.Invalid -> this + null -> copy(currentUserId = null) + } + enum class SessionTransitionReason { SELF_LOGOUT, REMOVED_CLIENT, diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelGraph.kt index 62d4a2e469a..83ec08f93a2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelGraph.kt @@ -25,14 +25,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.wire.android.di.metro.LocalWireViewModelScopeKey import com.wire.android.di.metro.MetroViewModelGraph -import com.wire.android.di.metro.sessionKeyedMetroViewModelKey +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel +import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs +import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs import com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel import com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel +import com.wire.android.ui.authentication.create.overview.CreateAccountOverviewNavArgs import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel +import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryNavArgs import com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel import com.wire.android.ui.authentication.devices.common.ClearSessionViewModel import com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel @@ -40,21 +44,36 @@ import com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel import com.wire.android.ui.authentication.login.LoginNavArgs import com.wire.android.ui.authentication.login.email.LoginEmailViewModel import com.wire.android.ui.authentication.login.sso.LoginSSOViewModel +import com.wire.android.ui.authentication.welcome.WelcomeNavArgs import com.wire.android.ui.authentication.welcome.WelcomeViewModel import com.wire.android.ui.newauthentication.login.NewLoginViewModel import com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel import com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel import com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel +import com.wire.android.ui.registration.selector.CreateAccountSelectorNavArgs import com.wire.kalium.logic.data.user.UserId import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory -import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel -import dev.zacsweers.metrox.viewmodel.metroViewModel as metroxViewModel interface AuthenticationViewModelGraph : MetroViewModelGraph interface AuthenticationManualViewModelFactory : ManualViewModelAssistedFactory { + fun welcomeViewModel(navArgs: WelcomeNavArgs): WelcomeViewModel + fun newLoginViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): NewLoginViewModel fun loginEmailViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): LoginEmailViewModel fun loginSSOViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras): LoginSSOViewModel + fun createAccountOverviewViewModel(navArgs: CreateAccountOverviewNavArgs): CreateAccountOverviewViewModel + fun createAccountEmailViewModel(navArgs: CreateAccountNavArgs): CreateAccountEmailViewModel + fun createAccountDetailsViewModel(navArgs: CreateAccountNavArgs): CreateAccountDetailsViewModel + fun createAccountCodeViewModel(navArgs: CreateAccountNavArgs): CreateAccountCodeViewModel + fun createAccountSummaryViewModel(navArgs: CreateAccountSummaryNavArgs): CreateAccountSummaryViewModel + fun createAccountSelectorViewModel(navArgs: CreateAccountSelectorNavArgs): CreateAccountSelectorViewModel + fun createAccountDataDetailViewModel(navArgs: CreateAccountDataNavArgs): CreateAccountDataDetailViewModel + fun createAccountVerificationCodeViewModel( + navArgs: CreateAccountDataNavArgs, + ): CreateAccountVerificationCodeViewModel +} + +interface SessionAuthenticationManualViewModelFactory : ManualViewModelAssistedFactory { fun clearSessionViewModel(cancelUserId: UserId?): ClearSessionViewModel } @@ -67,21 +86,45 @@ inline fun authenticationViewModel( viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, - key: String? = null, ): VM where VM : ViewModel = - metroxViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = authenticationViewModelKey(viewModelStoreOwner, key), + wireMetroViewModel( + owner = viewModelStoreOwner, ) @Composable fun welcomeViewModel(): WelcomeViewModel = authenticationViewModel() +@Composable +fun welcomeViewModel( + navArgs: WelcomeNavArgs, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, +): WelcomeViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + welcomeViewModel(navArgs) + } + @Composable fun newLoginViewModel(): NewLoginViewModel = authenticationViewModel() +@Composable +fun newLoginViewModel( + loginNavArgs: LoginNavArgs, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, +): NewLoginViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { extras -> + newLoginViewModel(loginNavArgs, extras) + } + @Composable fun loginEmailViewModel( loginNavArgs: LoginNavArgs, @@ -89,9 +132,8 @@ fun loginEmailViewModel( "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, ): LoginEmailViewModel = - assistedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = authenticationViewModelKey(viewModelStoreOwner, loginNavArgs.toString()), + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, ) { extras -> loginEmailViewModel(loginNavArgs, extras) } @@ -103,30 +145,31 @@ fun loginSSOViewModel( "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, ): LoginSSOViewModel = - assistedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = authenticationViewModelKey(viewModelStoreOwner, loginNavArgs.ssoViewModelKey()), + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, ) { extras -> loginSSOViewModel(loginNavArgs, extras) } @Composable -fun registerDeviceViewModel(): RegisterDeviceViewModel = - authenticationViewModel() +fun registerDeviceViewModel( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): RegisterDeviceViewModel = + authenticationViewModel(viewModelStoreOwner) @Composable -fun removeDeviceViewModel(): RemoveDeviceViewModel = - authenticationViewModel() +fun removeDeviceViewModel( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): RemoveDeviceViewModel = + authenticationViewModel(viewModelStoreOwner) @Composable -fun clearSessionViewModel(): ClearSessionViewModel { +fun clearSessionViewModel( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): ClearSessionViewModel { val cancelUserId = LocalAuthenticationCancelUserId.current - val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - } - return assistedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = authenticationViewModelKey(viewModelStoreOwner, cancelUserId?.toString()), + return wireAssistedMetroViewModel( + owner = viewModelStoreOwner, ) { clearSessionViewModel(cancelUserId) } @@ -169,14 +212,95 @@ fun createAccountVerificationCodeViewModel(): CreateAccountVerificationCodeViewM authenticationViewModel() @Composable -inline fun authenticationViewModelKey( +fun createAccountUsernameViewModel( viewModelStoreOwner: ViewModelStoreOwner, - key: String? = null, -): String? = - sessionKeyedMetroViewModelKey( - defaultKey = VM::class.qualifiedName, - key = listOfNotNull(key, viewModelStoreOwner.hashCode().toString()).joinToString(":"), - scopeKey = LocalWireViewModelScopeKey.current, - ) +): CreateAccountUsernameViewModel = + authenticationViewModel(viewModelStoreOwner = viewModelStoreOwner) -private fun LoginNavArgs.ssoViewModelKey(): String = copy(ssoLoginResult = null).toString() +@Composable +fun createAccountOverviewViewModel( + navArgs: CreateAccountOverviewNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountOverviewViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountOverviewViewModel(navArgs) + } + +@Composable +fun createAccountEmailViewModel( + navArgs: CreateAccountNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountEmailViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountEmailViewModel(navArgs) + } + +@Composable +fun createAccountDetailsViewModel( + navArgs: CreateAccountNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountDetailsViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountDetailsViewModel(navArgs) + } + +@Composable +fun createAccountCodeViewModel( + navArgs: CreateAccountNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountCodeViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountCodeViewModel(navArgs) + } + +@Composable +fun createAccountSummaryViewModel( + navArgs: CreateAccountSummaryNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountSummaryViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountSummaryViewModel(navArgs) + } + +@Composable +fun createAccountSelectorViewModel( + navArgs: CreateAccountSelectorNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountSelectorViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountSelectorViewModel(navArgs) + } + +@Composable +fun createAccountDataDetailViewModel( + navArgs: CreateAccountDataNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountDataDetailViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountDataDetailViewModel(navArgs) + } + +@Composable +fun createAccountVerificationCodeViewModel( + navArgs: CreateAccountDataNavArgs, + viewModelStoreOwner: ViewModelStoreOwner, +): CreateAccountVerificationCodeViewModel = + wireAssistedMetroViewModel( + owner = viewModelStoreOwner, + ) { + createAccountVerificationCodeViewModel(navArgs) + } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/SessionAuthenticationViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/SessionAuthenticationViewModelFactory.kt new file mode 100644 index 00000000000..25a75b5e276 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/SessionAuthenticationViewModelFactory.kt @@ -0,0 +1,98 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.authentication + +import com.wire.android.analytics.FinalizeRegistrationAnalyticsMetadataUseCase +import com.wire.android.analytics.RegistrationAnalyticsManagerUseCase +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.di.CurrentAccount +import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel +import com.wire.android.ui.authentication.devices.common.ClearSessionViewModel +import com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel +import com.wire.android.util.ui.CountdownTimer +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.auth.LogoutUseCase +import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase +import com.wire.kalium.logic.feature.auth.verification.RequestSecondFactorVerificationCodeUseCase +import com.wire.kalium.logic.feature.client.DeleteClientUseCase +import com.wire.kalium.logic.feature.client.FetchSelfClientsFromRemoteUseCase +import com.wire.kalium.logic.feature.client.GetOrRegisterClientUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionUseCase +import com.wire.kalium.logic.feature.session.DeleteSessionUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase +import com.wire.kalium.logic.feature.user.SetUserHandleUseCase +import dev.zacsweers.metro.Inject + +/** Creates only ViewModels whose dependencies belong to one explicit session graph. */ +@Suppress("LongParameterList") +class SessionAuthenticationViewModelFactory @Inject constructor( + @CurrentAccount private val currentAccount: UserId, + private val userDataStoreProvider: UserDataStoreProvider, + private val getOrRegisterClient: GetOrRegisterClientUseCase, + private val isPasswordRequired: IsPasswordRequiredUseCase, + private val getSelfUser: GetSelfUserUseCase, + private val requestSecondFactorVerificationCode: RequestSecondFactorVerificationCodeUseCase, + private val countdownTimer: CountdownTimer, + private val fetchSelfClientsFromRemote: FetchSelfClientsFromRemoteUseCase, + private val deleteClient: DeleteClientUseCase, + private val currentSession: CurrentSessionUseCase, + private val deleteSession: DeleteSessionUseCase, + private val switchAccount: AccountSwitchUseCase, + private val logout: LogoutUseCase, + private val validateUserHandle: ValidateUserHandleUseCase, + private val setUserHandle: SetUserHandleUseCase, + private val finalizeRegistrationAnalyticsMetadata: FinalizeRegistrationAnalyticsMetadataUseCase, + private val registrationAnalyticsManager: RegistrationAnalyticsManagerUseCase, +) { + fun registerDeviceViewModel() = RegisterDeviceViewModel( + registerClientUseCase = getOrRegisterClient, + isPasswordRequired = isPasswordRequired, + userDataStore = userDataStoreProvider.getOrCreate(currentAccount), + getSelfUser = getSelfUser, + requestSecondFactorVerificationCodeUseCase = requestSecondFactorVerificationCode, + resendCodeTimer = countdownTimer, + ) + + fun removeDeviceViewModel() = RemoveDeviceViewModel( + fetchSelfClientsFromRemote = fetchSelfClientsFromRemote, + deleteClientUseCase = deleteClient, + registerClientUseCase = getOrRegisterClient, + isPasswordRequired = isPasswordRequired, + userDataStore = userDataStoreProvider.getOrCreate(currentAccount), + getSelfUser = getSelfUser, + requestSecondFactorVerificationCodeUseCase = requestSecondFactorVerificationCode, + ) + + fun clearSessionViewModel(cancelUserId: UserId?) = ClearSessionViewModel( + currentSession = currentSession, + deleteSession = deleteSession, + switchAccount = switchAccount, + logout = logout, + cancelUserId = cancelUserId, + ) + + fun createAccountUsernameViewModel() = CreateAccountUsernameViewModel( + validateUserHandleUseCase = validateUserHandle, + setUserHandleUseCase = setUserHandle, + finalizeRegistrationAnalyticsMetadata = finalizeRegistrationAnalyticsMetadata, + registrationAnalyticsManager = registrationAnalyticsManager, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeScreen.kt index 158a924c8bd..e23c715cc2e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.create.code -import com.wire.android.navigation.annotation.app.WireCreateTeamAccountDestination import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -41,16 +40,10 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.wire.android.ui.authentication.createAccountCodeViewModel import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.CreateAccountFlowType -import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.create.summary.CreateAccountSummaryNavArgs -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.authentication.verificationcode.ResendCodeText import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -61,8 +54,6 @@ import com.wire.android.ui.common.textfield.CodeTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSummaryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination import com.wire.android.ui.registration.code.CreateAccountCodeResult import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -72,25 +63,19 @@ import com.wire.android.util.dialogErrorStrings import com.wire.kalium.logic.configuration.server.ServerConfig import kotlinx.coroutines.job -@WireCreateTeamAccountDestination(navArgs = CreateAccountNavArgs::class) @Composable -fun CreateAccountCodeScreen( - navigator: Navigator, - createAccountCodeViewModel: CreateAccountCodeViewModel = createAccountCodeViewModel() +internal fun CreateAccountCodeRouteScreen( + viewModel: CreateAccountCodeViewModel, + onNavigateBack: () -> Unit, + onSuccess: (CreateAccountSummaryNavArgs, com.wire.kalium.logic.data.user.UserId) -> Unit, + onTooManyDevices: (com.wire.kalium.logic.data.user.UserId) -> Unit, ) { - with(createAccountCodeViewModel) { - fun navigateToSummaryScreen() = navigator.navigate( - NavigationCommand( - CreateAccountSummaryScreenDestination(CreateAccountSummaryNavArgs(createAccountNavArgs.flowType)), - BackStackMode.CLEAR_WHOLE - ) - ) - + with(viewModel) { CodeContent( state = codeState, textState = codeTextState, onResendCodePressed = ::resendCode, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, serverConfig = serverConfig ) @@ -108,19 +93,14 @@ fun CreateAccountCodeScreen( ) } LaunchedEffect(codeState.result) { - if (codeState.result is CreateAccountCodeResult.Success) { - navigateToSummaryScreen() + (codeState.result as? CreateAccountCodeResult.Success)?.let { + onSuccess(CreateAccountSummaryNavArgs(createAccountNavArgs.flowType), it.userId) } val tooManyDevicesError = codeState.result as? CreateAccountCodeResult.Error.TooManyDevicesError if (tooManyDevicesError != null) { clearCodeError() clearCodeField() - navigator.navigate( - NavigationCommand( - RemoveDeviceScreenDestination(SessionBackedAuthenticationNavArgs.from(tooManyDevicesError.userId)), - BackStackMode.CLEAR_WHOLE - ) - ) + onTooManyDevices(tooManyDevicesError.userId) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt index ff2d21ada07..f5fdbb2a3c7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt @@ -22,10 +22,8 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.BuildConfig import com.wire.android.di.ClientScopeProvider import com.wire.android.di.DefaultWebSocketEnabledByDefault @@ -56,7 +54,7 @@ import dev.zacsweers.metro.AssistedInject // TODO: Cover this viewModel with unit test class CreateAccountCodeViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val createAccountNavArgs: CreateAccountNavArgs, @KaliumCoreLogic private val coreLogic: CoreLogic, private val addAuthenticatedUser: AddAuthenticatedUserUseCase, private val clientScopeProviderFactory: ClientScopeProvider.Factory, @@ -65,11 +63,8 @@ class CreateAccountCodeViewModel @AssistedInject constructor( ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountCodeViewModel + fun create(createAccountNavArgs: CreateAccountNavArgs): CreateAccountCodeViewModel } - - val createAccountNavArgs: CreateAccountNavArgs = savedStateHandle.navArgs() - val serverConfig: ServerConfig.Links = createAccountNavArgs.customServerConfig ?: defaultServerConfig val codeTextState: TextFieldState = TextFieldState() @@ -213,12 +208,12 @@ class CreateAccountCodeViewModel @AssistedInject constructor( } is RegisterClientResult.Success -> { - codeState = codeState.copy(result = CreateAccountCodeResult.Success) + codeState = codeState.copy(result = CreateAccountCodeResult.Success(storedUserId)) } is RegisterClientResult.E2EICertificateRequired -> { // TODO - codeState = codeState.copy(result = CreateAccountCodeResult.Success) + codeState = codeState.copy(result = CreateAccountCodeResult.Success(storedUserId)) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/CreateAccountNavGraph.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/CreateAccountNavGraph.kt deleted file mode 100644 index e57813a54e9..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/CreateAccountNavGraph.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.ui.authentication.create.common - -import com.ramcosta.composedestinations.annotation.NavGraph -import com.wire.android.navigation.WireRootNavGraph -import com.wire.android.navigation.style.DefaultNestedNavGraphAnimations - -@Deprecated("These destinations belongs to the old registration flow, please use the new one [CreateAccountNavGraph]") -@NavGraph(defaultTransitions = DefaultNestedNavGraphAnimations::class) -annotation class CreatePersonalAccountNavGraph( - val start: Boolean = false -) - -@Deprecated("These destinations belongs to the old registration flow, please use the new one [CreateAccountNavGraph]") -@NavGraph(defaultTransitions = DefaultNestedNavGraphAnimations::class) -annotation class CreateTeamAccountNavGraph( - val start: Boolean = false -) - -@NavGraph -annotation class CreateAccountNavGraph( - val start: Boolean = false -) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt index 2a955120681..3be51c817fa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.create.details -import com.wire.android.navigation.annotation.app.WireCreateTeamAccountDestination import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -44,10 +43,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import com.wire.android.ui.authentication.createAccountDetailsViewModel import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs @@ -62,7 +58,6 @@ import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountCodeScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -70,30 +65,26 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig -@WireCreateTeamAccountDestination(navArgs = CreateAccountNavArgs::class) @Composable -fun CreateAccountDetailsScreen( - navigator: Navigator, - createAccountDetailsViewModel: CreateAccountDetailsViewModel = createAccountDetailsViewModel() +internal fun CreateAccountDetailsRouteScreen( + viewModel: CreateAccountDetailsViewModel, + onNavigateBack: () -> Unit, + onCodeRequested: (CreateAccountNavArgs) -> Unit, ) { - with(createAccountDetailsViewModel) { - fun navigateToCodeScreen() = navigator.navigate( - NavigationCommand( - CreateAccountCodeScreenDestination( + with(viewModel) { + LaunchedEffect(detailsState.success) { + if (detailsState.success) { + onCodeRequested( createAccountNavArgs.copy( userRegistrationInfo = createAccountNavArgs.userRegistrationInfo.copy( firstName = firstNameTextState.text.toString().trim(), lastName = lastNameTextState.text.toString().trim(), password = passwordTextState.text.toString(), - teamName = teamNameTextState.text.toString().trim() + teamName = teamNameTextState.text.toString().trim(), ) ) ) - ) - ) - - LaunchedEffect(createAccountDetailsViewModel.detailsState.success) { - if (createAccountDetailsViewModel.detailsState.success) navigateToCodeScreen() + } } DetailsContent( @@ -103,7 +94,7 @@ fun CreateAccountDetailsScreen( passwordTextState = passwordTextState, confirmPasswordTextState = confirmPasswordTextState, teamNameTextState = teamNameTextState, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, onContinuePressed = ::onDetailsContinue, onErrorDismiss = ::onDetailsErrorDismiss, serverConfig = serverConfig diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModel.kt index fe1ec237f5a..aaa0c4015af 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModel.kt @@ -21,13 +21,11 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs import com.wire.android.ui.common.textfield.textAsFlow -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import kotlinx.coroutines.flow.combine @@ -38,17 +36,14 @@ import dev.zacsweers.metro.AssistedInject // TODO: Cover this viewModel with unit test class CreateAccountDetailsViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val createAccountNavArgs: CreateAccountNavArgs, private val validatePasswordUseCase: ValidatePasswordUseCase, defaultServerConfig: ServerConfig.Links ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountDetailsViewModel + fun create(createAccountNavArgs: CreateAccountNavArgs): CreateAccountDetailsViewModel } - - val createAccountNavArgs: CreateAccountNavArgs = savedStateHandle.navArgs() - val firstNameTextState: TextFieldState = TextFieldState() val lastNameTextState: TextFieldState = TextFieldState() val passwordTextState: TextFieldState = TextFieldState() diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt index c8e20177033..8196a2a0537 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.create.email -import com.wire.android.navigation.annotation.app.WireCreateTeamAccountDestination import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -50,11 +49,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.wire.android.ui.authentication.createAccountEmailViewModel import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs @@ -71,8 +66,6 @@ import com.wire.android.ui.common.textfield.DefaultEmailDone import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -83,31 +76,20 @@ import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig -@WireCreateTeamAccountDestination(navArgs = CreateAccountNavArgs::class) @Composable -fun CreateAccountEmailScreen( - navigator: Navigator, - createAccountEmailViewModel: CreateAccountEmailViewModel = createAccountEmailViewModel() +internal fun CreateAccountEmailRouteScreen( + viewModel: CreateAccountEmailViewModel, + onNavigateBack: () -> Unit, + onLogin: () -> Unit, + onDetailsRequested: (CreateAccountNavArgs) -> Unit, ) { - with(createAccountEmailViewModel) { - fun navigateToDetailsScreen() = navigator.navigate( - NavigationCommand( - CreateAccountDetailsScreenDestination( - navArgs = createAccountNavArgs.copy( - userRegistrationInfo = UserRegistrationInfo( - email = emailTextState.text.trim().toString().lowercase() - ) - ) - ) - ) - ) - + with(viewModel) { EmailContent( state = emailState, emailTextState = emailTextState, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, onContinuePressed = ::onEmailContinue, - onLoginPressed = { navigator.navigate(NavigationCommand(LoginScreenDestination(), BackStackMode.CLEAR_TILL_START)) }, + onLoginPressed = onLogin, onTermsDialogDismiss = ::onTermsDialogDismiss, onTermsAccept = ::onTermsAccept, onErrorDismiss = ::onEmailErrorDismiss, @@ -116,7 +98,15 @@ fun CreateAccountEmailScreen( ) LaunchedEffect(emailState.success) { - if (emailState.success) navigateToDetailsScreen() + if (emailState.success) { + onDetailsRequested( + createAccountNavArgs.copy( + userRegistrationInfo = UserRegistrationInfo( + email = emailTextState.text.trim().toString().lowercase() + ) + ) + ) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt index 34b9af68ab3..8b171d3d413 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt @@ -21,13 +21,11 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.di.KaliumCoreLogic import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs import com.wire.android.ui.common.textfield.textAsFlow -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.ValidateEmailUseCase @@ -41,18 +39,15 @@ import dev.zacsweers.metro.AssistedInject // TODO: Cover this viewModel with unit test class CreateAccountEmailViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val createAccountNavArgs: CreateAccountNavArgs, private val validateEmail: ValidateEmailUseCase, @KaliumCoreLogic private val coreLogic: CoreLogic, defaultServerConfig: ServerConfig.Links ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountEmailViewModel + fun create(createAccountNavArgs: CreateAccountNavArgs): CreateAccountEmailViewModel } - - val createAccountNavArgs: CreateAccountNavArgs = savedStateHandle.navArgs() - val emailTextState: TextFieldState = TextFieldState() var emailState: CreateAccountEmailViewState by mutableStateOf(CreateAccountEmailViewState(createAccountNavArgs.flowType)) private set diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreateAccountOverviewViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreateAccountOverviewViewModel.kt index f354f460acf..1d9ffe2302c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreateAccountOverviewViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreateAccountOverviewViewModel.kt @@ -17,24 +17,20 @@ */ package com.wire.android.ui.authentication.create.overview -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.configuration.server.ServerConfig import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class CreateAccountOverviewViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val navArgs: CreateAccountOverviewNavArgs, defaultServerConfig: ServerConfig.Links ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountOverviewViewModel + fun create(navArgs: CreateAccountOverviewNavArgs): CreateAccountOverviewViewModel } - - val navArgs: CreateAccountOverviewNavArgs = savedStateHandle.navArgs() val serverConfig: ServerConfig.Links = navArgs.customServerConfig ?: defaultServerConfig fun learnMoreUrl(): String = serverConfig.pricing } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreatePersonalAccountOverviewScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreatePersonalAccountOverviewScreen.kt index 0225963cc3c..b65408cadca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreatePersonalAccountOverviewScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/overview/CreatePersonalAccountOverviewScreen.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.authentication.create.overview -import com.wire.android.navigation.annotation.app.WireCreatePersonalAccountDestination -import com.wire.android.navigation.annotation.app.WireCreateTeamAccountDestination import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -43,11 +41,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.ui.authentication.createAccountOverviewViewModel import com.wire.android.R import com.wire.android.ui.common.R as commonR -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle @@ -56,49 +51,30 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountEmailScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.kalium.logic.configuration.server.ServerConfig -@WireCreatePersonalAccountDestination(start = true, navArgs = CreateAccountOverviewNavArgs::class) @Composable -fun CreatePersonalAccountOverviewScreen( - navigator: Navigator, - viewModel: CreateAccountOverviewViewModel = createAccountOverviewViewModel() -) { - CreateAccountOverviewScreen(navigator, CreateAccountFlowType.CreatePersonalAccount, viewModel) -} - -@WireCreateTeamAccountDestination(start = true, navArgs = CreateAccountOverviewNavArgs::class) -@Composable -fun CreateTeamAccountOverviewScreen( - navigator: Navigator, - viewModel: CreateAccountOverviewViewModel = createAccountOverviewViewModel() -) { - CreateAccountOverviewScreen(navigator, CreateAccountFlowType.CreateTeam, viewModel) -} - -@Composable -fun CreateAccountOverviewScreen( - navigator: Navigator, +internal fun CreateAccountOverviewRouteScreen( flowType: CreateAccountFlowType, viewModel: CreateAccountOverviewViewModel, + onNavigateBack: () -> Unit, + onContinue: (CreateAccountNavArgs) -> Unit, ) { with(flowType) { - fun navigateToEmailScreen() { - val createAccountNavArgs = CreateAccountNavArgs( - flowType = this, - customServerConfig = viewModel.navArgs.customServerConfig - ) - navigator.navigate(NavigationCommand(CreateAccountEmailScreenDestination(createAccountNavArgs))) - } - OverviewContent( - onBackPressed = navigator::navigateBack, - onContinuePressed = ::navigateToEmailScreen, + onBackPressed = onNavigateBack, + onContinuePressed = { + onContinue( + CreateAccountNavArgs( + flowType = this, + customServerConfig = viewModel.navArgs.customServerConfig, + ) + ) + }, serverConfig = viewModel.serverConfig, overviewParams = CreateAccountOverviewParams( title = stringResource(id = titleResId), diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryScreen.kt index eb91665dcbf..80527a4f5dd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.create.summary -import com.wire.android.navigation.annotation.app.WireCreateTeamAccountDestination import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -35,30 +34,22 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.ui.authentication.createAccountSummaryViewModel import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountUsernameScreenDestination import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -@WireCreateTeamAccountDestination(navArgs = CreateAccountSummaryNavArgs::class) @Composable -fun CreateAccountSummaryScreen( - navigator: Navigator, - viewModel: CreateAccountSummaryViewModel = createAccountSummaryViewModel() +internal fun CreateAccountSummaryRouteScreen( + viewModel: CreateAccountSummaryViewModel, + onContinue: () -> Unit, ) { SummaryContent( state = viewModel.summaryState, - onContinuePressed = { - navigator.navigate(NavigationCommand(CreateAccountUsernameScreenDestination, BackStackMode.CLEAR_WHOLE)) - } + onContinuePressed = onContinue, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryViewModel.kt index 573a6206e38..04a6dce6863 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/summary/CreateAccountSummaryViewModel.kt @@ -21,23 +21,19 @@ package com.wire.android.ui.authentication.create.summary import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.wire.android.ui.authentication.create.common.CreateAccountFlowType -import com.ramcosta.composedestinations.generated.app.navArgs import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class CreateAccountSummaryViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle + @Assisted createAccountSummaryNavArgs: CreateAccountSummaryNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountSummaryViewModel + fun create(createAccountSummaryNavArgs: CreateAccountSummaryNavArgs): CreateAccountSummaryViewModel } - - private val createAccountSummaryNavArgs: CreateAccountSummaryNavArgs = savedStateHandle.navArgs() private val type: CreateAccountFlowType = createAccountSummaryNavArgs.type var summaryState: CreateAccountSummaryViewState by mutableStateOf(CreateAccountSummaryViewState(type)) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt index a31cd525edd..5f7c0aa12e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.create.username -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -34,19 +33,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import com.wire.android.ui.authentication.createAccountUsernameViewModel import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthPopUpNavigationAnimation import com.wire.android.ui.authentication.create.common.handle.UsernameTextField import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.EdgeToEdgePreview -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination import com.wire.android.ui.newauthentication.login.NewAuthContainer import com.wire.android.ui.newauthentication.login.NewAuthHeader import com.wire.android.ui.theme.WireTheme @@ -54,13 +47,10 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination( - style = AuthPopUpNavigationAnimation::class -) @Composable -fun CreateAccountUsernameScreen( - navigator: Navigator, - viewModel: CreateAccountUsernameViewModel = createAccountUsernameViewModel() +internal fun CreateAccountUsernameRouteScreen( + viewModel: CreateAccountUsernameViewModel, + onSuccess: () -> Unit, ) { UsernameContent( textState = viewModel.textState, @@ -71,12 +61,7 @@ fun CreateAccountUsernameScreen( LaunchedEffect(viewModel.state.success) { if (viewModel.state.success) { - navigator.navigate( - NavigationCommand( - InitialSyncScreenDestination, - BackStackMode.CLEAR_WHOLE - ) - ) + onSuccess() } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModel.kt index b97c2833423..f7421b9e671 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModel.kt @@ -33,7 +33,9 @@ import com.wire.kalium.logic.feature.auth.LogoutUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.session.DeleteSessionUseCase +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -66,17 +68,30 @@ class ClearSessionViewModel @AssistedInject constructor( fun onCancelLoginClicked(switchAccountActions: SwitchAccountActions) { state = state.copy(showCancelLoginDialog = false) viewModelScope.launch { - val userId = cancelUserId ?: currentSessionUserId() - if (userId != null) { - // logout to cancel all session-related actions, remove all sensitive data and free up resources - logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) - // delete the session to make it seem like the session was never logged in - deleteSession(userId) - } - }.invokeOnCompletion { - viewModelScope.launch { - switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) - .callAction(switchAccountActions) + cancelLogin(switchAccountActions) + } + } + + internal suspend fun cancelLogin(switchAccountActions: SwitchAccountActions) { + val userId = cancelUserId ?: currentSessionUserId() + // Select the account to return to while the unfinished login is still the current + // session. If logout runs first, current-session observation briefly becomes empty and + // Navigation 3 can dispose this route before the account switch is attempted. + try { + val switchResult = switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) + // Remove the session-backed authentication route before deleting the account that owns + // its Metro graph. Navigation 3 can keep an outgoing entry composed for its exit + // transition; deleting first would make that entry recreate a graph for a session that + // no longer exists. + switchResult.callAction(switchAccountActions) + } finally { + withContext(NonCancellable) { + if (userId != null) { + // logout to cancel all session-related actions, remove all sensitive data and free up resources + logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) + // delete the session to make it seem like the session was never logged in + deleteSession(userId) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/SessionBackedAuthenticationLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/SessionBackedAuthenticationLegacyMappers.kt new file mode 100644 index 00000000000..9a4063beeae --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/common/SessionBackedAuthenticationLegacyMappers.kt @@ -0,0 +1,39 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.authentication.devices.common + +import com.wire.navigation.WireSessionId + +/** + * Bridges the nullable pair used by the legacy generated destination to a typed Navigation 3 + * session identity. + * + * These screens are rendered from a session Metro graph. Silently treating a partially populated + * identity as an authentication-scoped route could attach the screen to the wrong account, so the + * migration bridge rejects that state. + */ +internal fun SessionBackedAuthenticationNavArgs.requireWireSessionId(): WireSessionId { + val value = requireNotNull(userIdValue) { + "A session-backed authentication route requires a user id value" + } + val domain = requireNotNull(userIdDomain) { + "A session-backed authentication route requires a user id domain" + } + return WireSessionId(value, domain) +} diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AppImageAssetViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceLegacyMappers.kt similarity index 57% rename from app/src/main/kotlin/com/wire/android/di/metro/AppImageAssetViewModelGraph.kt rename to app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceLegacyMappers.kt index cf5599be321..b0fabb49c2c 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/AppImageAssetViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceLegacyMappers.kt @@ -15,15 +15,16 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.di.metro -import com.wire.android.ui.common.CommonViewModelGraph -import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel -import dev.zacsweers.metro.Inject +package com.wire.android.ui.authentication.devices.register -class AppImageAssetViewModelGraph @Inject constructor( - private val commonTopAppBarViewModelFactoryProvider: () -> CommonTopAppBarViewModel.Factory, -) : CommonViewModelGraph { - override val commonTopAppBarViewModelFactory: CommonTopAppBarViewModel.Factory - get() = commonTopAppBarViewModelFactoryProvider() -} +import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs +import com.wire.android.ui.authentication.devices.common.requireWireSessionId +import com.wire.navigation.WireNavEntryId + +internal fun SessionBackedAuthenticationNavArgs.toRegisterDeviceRoute( + entryId: WireNavEntryId = WireNavEntryId.random(), +) = RegisterDeviceRoute( + sessionId = requireWireSessionId(), + entryId = entryId, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceRoute.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceRoute.kt new file mode 100644 index 00000000000..fcfbd70b7aa --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceRoute.kt @@ -0,0 +1,40 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.authentication.devices.register + +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import com.wire.navigation.authenticationSessionFlowId +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterDeviceRoute( + override val sessionId: WireSessionId, + override val flowId: String = sessionId.authenticationSessionFlowId(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/register_device_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt index 879768685e7..e3d8fa55bb7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.devices.register -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith @@ -39,19 +38,10 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import com.wire.android.ui.authentication.clearSessionViewModel -import com.wire.android.ui.authentication.registerDeviceViewModel import com.wire.android.R -import com.wire.android.feature.NavigationSwitchAccountActions -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.navigation.style.TransitionAnimationType import com.wire.android.ui.authentication.devices.common.ClearSessionState import com.wire.android.ui.authentication.devices.common.ClearSessionViewModel -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dialogs.CancelLoginDialogContent @@ -66,51 +56,32 @@ import com.wire.android.ui.common.textfield.clearAutofillTree import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination( - style = PopUpNavigationAnimation::class, - navArgs = SessionBackedAuthenticationNavArgs::class, -) @Composable -fun RegisterDeviceScreen( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, - sessionBackedAuthenticationNavArgs: SessionBackedAuthenticationNavArgs, - viewModel: RegisterDeviceViewModel = registerDeviceViewModel(), - clearSessionViewModel: ClearSessionViewModel = clearSessionViewModel(), +internal fun RegisterDeviceRouteScreen( + viewModel: RegisterDeviceViewModel, + clearSessionViewModel: ClearSessionViewModel, + switchAccountActions: com.wire.android.feature.SwitchAccountActions, + onE2EIRequired: (com.wire.kalium.logic.data.user.UserId?) -> Unit, + onHomeRequired: () -> Unit, + onInitialSyncRequired: () -> Unit, + onRemoveDeviceRequired: () -> Unit, ) { clearAutofillTree() when (val flowState = viewModel.state.flowState) { is RegisterDeviceFlowState.Success -> { - navigator.navigate( - NavigationCommand( - destination = if (flowState.isE2EIRequired) { - E2EIEnrollmentScreenDestination( - flowState.userId - ?.let(SessionBackedAuthenticationNavArgs::from) - ?: sessionBackedAuthenticationNavArgs - ) - } else if (flowState.initialSyncCompleted) { - HomeScreenDestination - } else { - InitialSyncScreenDestination - }, - backStackMode = BackStackMode.CLEAR_WHOLE - ) - ) + when { + flowState.isE2EIRequired -> onE2EIRequired(flowState.userId) + flowState.initialSyncCompleted -> onHomeRequired() + else -> onInitialSyncRequired() + } } - is RegisterDeviceFlowState.TooManyDevices -> navigator.navigate( - NavigationCommand(RemoveDeviceScreenDestination(sessionBackedAuthenticationNavArgs)) - ) + is RegisterDeviceFlowState.TooManyDevices -> onRemoveDeviceRequired() else -> AnimatedContent( targetState = viewModel.secondFactorVerificationCodeState.isCodeInputNecessary, @@ -130,9 +101,7 @@ fun RegisterDeviceScreen( onErrorDismiss = viewModel::onErrorDismiss, onBackButtonClicked = clearSessionViewModel::onBackButtonClicked, onCancelLoginClicked = { - clearSessionViewModel.onCancelLoginClicked( - NavigationSwitchAccountActions(navigator::navigate, loginTypeSelector::canUseNewLogin) - ) + clearSessionViewModel.onCancelLoginClicked(switchAccountActions) }, onProceedLoginClicked = clearSessionViewModel::onProceedLoginClicked ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/NewConversationNavGraph.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceLegacyMappers.kt similarity index 56% rename from app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/NewConversationNavGraph.kt rename to app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceLegacyMappers.kt index 989b9e44b14..2ba25ae5d6c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/NewConversationNavGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceLegacyMappers.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * Copyright (C) 2026 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,13 +16,15 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.newconversation.common +package com.wire.android.ui.authentication.devices.remove -import com.ramcosta.composedestinations.annotation.NavGraph -import com.wire.android.navigation.WireRootNavGraph -import com.wire.android.navigation.style.DefaultNestedNavGraphAnimations +import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs +import com.wire.android.ui.authentication.devices.common.requireWireSessionId +import com.wire.navigation.WireNavEntryId -@NavGraph(defaultTransitions = DefaultNestedNavGraphAnimations::class) -annotation class NewConversationNavGraph( - val start: Boolean = false +internal fun SessionBackedAuthenticationNavArgs.toRemoveDeviceRoute( + entryId: WireNavEntryId = WireNavEntryId.random(), +) = RemoveDeviceRoute( + sessionId = requireWireSessionId(), + entryId = entryId, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceRoute.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceRoute.kt new file mode 100644 index 00000000000..94a95156465 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceRoute.kt @@ -0,0 +1,40 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.authentication.devices.remove + +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import com.wire.navigation.authenticationSessionFlowId +import kotlinx.serialization.Serializable + +@Serializable +data class RemoveDeviceRoute( + override val sessionId: WireSessionId, + override val flowId: String = sessionId.authenticationSessionFlowId(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/remove_device_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt index 674b4a303f6..904d51344a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.authentication.devices.remove -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith @@ -37,20 +36,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.wire.android.ui.authentication.clearSessionViewModel -import com.wire.android.ui.authentication.removeDeviceViewModel import com.wire.android.R -import com.wire.android.feature.NavigationSwitchAccountActions -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.navigation.style.TransitionAnimationType import com.wire.android.ui.authentication.devices.DeviceItem import com.wire.android.ui.authentication.devices.common.ClearSessionState import com.wire.android.ui.authentication.devices.common.ClearSessionViewModel -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.SurfaceBackgroundWrapper @@ -65,39 +55,20 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.textfield.clearAutofillTree import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.common.wireDialogPropertiesBuilder -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.util.dialogErrorStrings import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.conversation.ClientId -@WireRootDestination( - style = PopUpNavigationAnimation::class, - navArgs = SessionBackedAuthenticationNavArgs::class, -) @Composable -fun RemoveDeviceScreen( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, - sessionBackedAuthenticationNavArgs: SessionBackedAuthenticationNavArgs, - viewModel: RemoveDeviceViewModel = removeDeviceViewModel(), - clearSessionViewModel: ClearSessionViewModel = clearSessionViewModel(), +internal fun RemoveDeviceRouteScreen( + viewModel: RemoveDeviceViewModel, + clearSessionViewModel: ClearSessionViewModel, + switchAccountActions: com.wire.android.feature.SwitchAccountActions, + onE2EIRequired: () -> Unit, + onHomeRequired: () -> Unit, + onInitialSyncRequired: () -> Unit, ) { - fun navigateAfterSuccess(initialSyncCompleted: Boolean, isE2EIRequired: Boolean) = navigator.navigate( - NavigationCommand( - destination = if (isE2EIRequired) { - E2EIEnrollmentScreenDestination(sessionBackedAuthenticationNavArgs) - } else if (initialSyncCompleted) { - HomeScreenDestination - } else { - InitialSyncScreenDestination - }, - backStackMode = BackStackMode.CLEAR_WHOLE - ) - ) - clearAutofillTree() AnimatedContent( @@ -120,9 +91,7 @@ fun RemoveDeviceScreen( onErrorDialogDismiss = viewModel::clearDeleteClientError, onBackButtonClicked = clearSessionViewModel::onBackButtonClicked, onCancelLoginClicked = { - clearSessionViewModel.onCancelLoginClicked( - NavigationSwitchAccountActions(navigator::navigate, loginTypeSelector::canUseNewLogin) - ) + clearSessionViewModel.onCancelLoginClicked(switchAccountActions) }, onProceedLoginClicked = clearSessionViewModel::onProceedLoginClicked ) @@ -150,7 +119,11 @@ fun RemoveDeviceScreen( HandleActions(viewModel.actions) { action -> when (action) { - is OnComplete -> navigateAfterSuccess(action.initialSyncCompleted, action.isE2EIRequired) + is OnComplete -> when { + action.isE2EIRequired -> onE2EIRequired() + action.initialSyncCompleted -> onHomeRequired() + else -> onInitialSyncRequired() + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavGraph.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavGraph.kt deleted file mode 100644 index 0703daa17d1..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginNavGraph.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.authentication.login - -import com.ramcosta.composedestinations.annotation.NavGraph -import com.wire.android.navigation.WireRootNavGraph - -@NavGraph -annotation class LoginNavGraph(val start: Boolean = false) - -@NavGraph -annotation class NewLoginNavGraph(val start: Boolean = false) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt index 530849508b9..cc011fb57c9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt @@ -44,21 +44,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination import com.wire.android.R import com.wire.android.ui.authentication.loginEmailViewModel -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireLoginDestination import com.wire.android.navigation.style.TransitionAnimationType import com.wire.android.ui.authentication.BackendConfigSuccessContent import com.wire.android.ui.authentication.MissingBackendConfigContent import com.wire.android.ui.authentication.create.common.ServerTitle -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.authentication.login.email.LoginEmailScreen import com.wire.android.ui.authentication.login.email.LoginEmailState import com.wire.android.ui.authentication.login.email.LoginEmailVerificationCodeScreen @@ -83,38 +74,21 @@ import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.launch -@WireLoginDestination( - start = true, - navArgs = LoginNavArgs::class -) +/** + * Navigation-neutral adapter used by the Navigation 3 host. + */ @Composable -fun LoginScreen( - navigator: Navigator, +internal fun LoginRouteScreen( loginNavArgs: LoginNavArgs, - loginEmailViewModel: LoginEmailViewModel = loginEmailViewModel(loginNavArgs) + loginEmailViewModel: LoginEmailViewModel, + onBackPressed: () -> Unit, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean, userId: UserId) -> Unit, + onRemoveDeviceNeeded: (UserId) -> Unit, ) { - LoginContent( - onBackPressed = navigator::navigateBack, - onSuccess = { initialSyncCompleted, isE2EIRequired, userId -> - val destination = if (isE2EIRequired) { - E2EIEnrollmentScreenDestination(SessionBackedAuthenticationNavArgs.from(userId)) - } else if (initialSyncCompleted) { - HomeScreenDestination - } else { - InitialSyncScreenDestination - } - - navigator.navigate(NavigationCommand(destination, BackStackMode.CLEAR_WHOLE)) - }, - onRemoveDeviceNeeded = { userId -> - navigator.navigate( - NavigationCommand( - RemoveDeviceScreenDestination(SessionBackedAuthenticationNavArgs.from(userId)), - BackStackMode.CLEAR_WHOLE, - ) - ) - }, + onBackPressed = onBackPressed, + onSuccess = onSuccess, + onRemoveDeviceNeeded = onRemoveDeviceNeeded, loginNavArgs = loginNavArgs, loginEmailViewModel = loginEmailViewModel, ssoLoginResult = loginNavArgs.ssoLoginResult, @@ -133,11 +107,6 @@ private fun LoginContent( ssoCodeAutoLogin: SSOCodeAutoLogin?, ) { Column(modifier = Modifier.fillMaxSize()) { - /* - TODO: we can change it to be a nested navigation graph when Compose Destinations 2.0 is released, - right now it's not possible to make start destination for nested graph with mandatory arguments. - More on that here: https://github.com/raamcosta/compose-destinations/issues/185 - */ AnimatedContent( targetState = loginEmailViewModel.secondFactorVerificationCodeState.isCodeInputNecessary, transitionSpec = { diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt index 8e0784d5247..59b37e9891b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt @@ -72,6 +72,8 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.Named import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -173,7 +175,7 @@ class LoginEmailViewModel( proxyIdentifierTextState.textAsFlow(), proxyPasswordTextState.textAsFlow() ) { _, _, _, _ -> }.collectLatest { - if (loginState.flowState != LoginState.Loading) { + if (loginState.flowState.canBeResetByCredentialChange()) { updateEmailFlowState(LoginState.Default, showInvalidCredentialsError = false) } } @@ -250,6 +252,15 @@ class LoginEmailViewModel( ) } + private fun LoginState.canBeResetByCredentialChange(): Boolean = when (this) { + LoginState.Loading, + LoginState.Canceled, + is LoginState.Success, + is LoginState.Error.TooManyDevicesError -> false + + else -> true + } + fun clearLoginErrors() { updateEmailFlowState(LoginState.Default) } @@ -279,111 +290,131 @@ class LoginEmailViewModel( @Suppress("LongMethod") private fun startLoginJob(usernameAllowed: Boolean): Job { return viewModelScope.launch { - // if username is not allowed, we need to check if the provided user identifier is an email - if (!usernameAllowed && !coreLogic.getGlobalScope().validateEmailUseCase(userIdentifierTextState.text.toString())) { - updateEmailFlowState(LoginState.Error.TextFieldError.InvalidValue) - return@launch - } - - val authScope = withContext(dispatchers.io()) { resolveCurrentAuthScope() } ?: return@launch - - val secondFactorVerificationCode = secondFactorVerificationCodeTextState.text.toString() - val loginResult = withContext(dispatchers.io()) { - authScope.login( - userIdentifier = userIdentifierTextState.text.toString(), - password = passwordTextState.text.toString(), - shouldPersistClient = true, - secondFactorVerificationCode = secondFactorVerificationCode - ) - } - if (loginResult !is AuthenticationResult.Success) { - loginResult as AuthenticationResult.Failure - handleAuthenticationFailure(loginResult, authScope) - return@launch - } - secondFactorVerificationCodeState = secondFactorVerificationCodeState.copy(isCodeInputNecessary = false) - loginJobData.update { it?.copy(newSessionUserId = loginResult.authData.userId) } - - val storedUserId = withContext(dispatchers.io()) { - addAuthenticatedUser( - StoreSessionParam( - accountTokens = loginResult.authData, - ssoId = loginResult.ssoID, - managedBy = loginResult.managedBy, - serverConfigId = loginResult.serverConfigId, - proxyCredentials = loginResult.proxyCredentials, - isPersistentWebSocketEnabled = defaultWebSocketEnabledByDefault, - ), - replace = false - ) - }.let { - when (it) { - is AddAuthenticatedUserUseCase.Result.Failure -> { - updateEmailFlowState(it.toLoginError()) - return@launch - } - - is AddAuthenticatedUserUseCase.Result.Success -> it.userId + var retainAuthenticatedSession = false + try { + // if username is not allowed, we need to check if the provided user identifier is an email + if (!usernameAllowed && !coreLogic.getGlobalScope().validateEmailUseCase(userIdentifierTextState.text.toString())) { + updateEmailFlowState(LoginState.Error.TextFieldError.InvalidValue) + return@launch } - } - withContext(dispatchers.io()) { - if (coreLogic.getGlobalScope().validateEmailUseCase(userIdentifierTextState.text.toString())) { - coreLogic.getSessionScope(storedUserId).users.persistSelfUserEmail(userIdentifierTextState.text.toString()) - } else { - null + val authScope = withContext(dispatchers.io()) { resolveCurrentAuthScope() } ?: return@launch + + val secondFactorVerificationCode = secondFactorVerificationCodeTextState.text.toString() + val loginResult = withContext(dispatchers.io()) { + authScope.login( + userIdentifier = userIdentifierTextState.text.toString(), + password = passwordTextState.text.toString(), + shouldPersistClient = true, + secondFactorVerificationCode = secondFactorVerificationCode + ) } - }.let { - if (it is PersistSelfUserEmailResult.Failure) { - revertNewSession() - updateEmailFlowState(LoginState.Error.DialogError.GenericError(it.coreFailure)) + if (loginResult !is AuthenticationResult.Success) { + loginResult as AuthenticationResult.Failure + handleAuthenticationFailure(loginResult, authScope) return@launch } - } - - withContext(dispatchers.io()) { - registerClient( - userId = storedUserId, - password = passwordTextState.text.toString(), - ) - }.let { - when (it) { - is RegisterClientResult.Success -> { - updateEmailFlowState(LoginState.Success(isInitialSyncCompleted(storedUserId), false, storedUserId)) + secondFactorVerificationCodeState = secondFactorVerificationCodeState.copy(isCodeInputNecessary = false) + + val storedUserId = withContext(dispatchers.io()) { + addAuthenticatedUser( + StoreSessionParam( + accountTokens = loginResult.authData, + ssoId = loginResult.ssoID, + managedBy = loginResult.managedBy, + serverConfigId = loginResult.serverConfigId, + proxyCredentials = loginResult.proxyCredentials, + isPersistentWebSocketEnabled = defaultWebSocketEnabledByDefault, + ), + replace = false + ) + }.let { + when (it) { + is AddAuthenticatedUserUseCase.Result.Failure -> { + updateEmailFlowState(it.toLoginError()) + return@launch + } + + is AddAuthenticatedUserUseCase.Result.Success -> it.userId } - - is RegisterClientResult.E2EICertificateRequired -> { - updateEmailFlowState(LoginState.Success(isInitialSyncCompleted(storedUserId), true, storedUserId)) + } + // Only a successfully persisted session belongs to this login attempt. Marking it + // earlier would make cancellation cleanup delete a pre-existing account when + // AddAuthenticatedUser reports UserAlreadyExists. + loginJobData.update { it?.copy(newSessionUserId = storedUserId) } + + withContext(dispatchers.io()) { + if (coreLogic.getGlobalScope().validateEmailUseCase(userIdentifierTextState.text.toString())) { + coreLogic.getSessionScope(storedUserId).users.persistSelfUserEmail(userIdentifierTextState.text.toString()) + } else { + null } - - is RegisterClientResult.Failure.TooManyClients -> { - updateEmailFlowState(LoginState.Error.TooManyDevicesError(storedUserId)) + }.let { + if (it is PersistSelfUserEmailResult.Failure) { + updateEmailFlowState(LoginState.Error.DialogError.GenericError(it.coreFailure)) + return@launch } + } - is RegisterClientResult.Failure -> { - revertNewSession() - updateEmailFlowState(it.toLoginError()) + withContext(dispatchers.io()) { + registerClient( + userId = storedUserId, + password = passwordTextState.text.toString(), + ) + }.let { + when (it) { + is RegisterClientResult.Success -> { + val initialSyncCompleted = isInitialSyncCompleted(storedUserId) + retainAuthenticatedSession = true + updateEmailFlowState(LoginState.Success(initialSyncCompleted, false, storedUserId)) + } + + is RegisterClientResult.E2EICertificateRequired -> { + val initialSyncCompleted = isInitialSyncCompleted(storedUserId) + retainAuthenticatedSession = true + updateEmailFlowState(LoginState.Success(initialSyncCompleted, true, storedUserId)) + } + + is RegisterClientResult.Failure.TooManyClients -> { + retainAuthenticatedSession = true + updateEmailFlowState(LoginState.Error.TooManyDevicesError(storedUserId)) + } + + is RegisterClientResult.Failure -> { + updateEmailFlowState(it.toLoginError()) + } + } + } + } finally { + val data = loginJobData.value + if (!retainAuthenticatedSession && data?.newSessionUserId != null) { + withContext(NonCancellable) { + revertNewSession(data) } } } } } - private suspend fun revertNewSession() { - loginJobData.value?.newSessionUserId?.let { newSessionUserId -> + private suspend fun revertNewSession(loginData: LoginJobData? = loginJobData.value) { + loginData?.newSessionUserId?.let { newSessionUserId -> // logout to cancel all session-related actions, remove all sensitive data and free up resources coreLogic.getSessionScope(newSessionUserId).logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) // delete the session to make it seem like the session was never logged in coreLogic.getGlobalScope().deleteSession(newSessionUserId) } // set the previous session back - coreLogic.getGlobalScope().session.updateCurrentSession(loginJobData.value?.previousSessionUserId) + coreLogic.getGlobalScope().session.updateCurrentSession(loginData?.previousSessionUserId) } private suspend fun revertLogin() { - loginJobData.value?.let { - it.job.cancel() - revertNewSession() + loginJobData.value?.let { loginData -> + loginData.job.cancelAndJoin() + // Real login jobs perform rollback from their non-cancellable finally block. This + // fallback also covers a job that completed before it installed that cleanup. + if (loginJobData.value?.newSessionUserId != null) { + revertNewSession(loginData) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt index 4955e4ecdcd..79a2ef11d6a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt @@ -16,11 +16,10 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -@file:Suppress("TooManyFunctions") +@file:Suppress("TooManyFunctions", "MatchingDeclarationName") package com.wire.android.ui.authentication.welcome -import com.wire.android.navigation.annotation.app.WireRootDestination import android.content.res.TypedArray import androidx.annotation.ArrayRes import androidx.annotation.DrawableRes @@ -66,19 +65,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.ui.authentication.welcomeViewModel import com.wire.android.BuildConfig.ENABLE_NEW_REGISTRATION import com.wire.android.R import com.wire.android.ui.common.R as commonR import com.wire.android.config.LocalCustomUiConfigurationProvider -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.authentication.MissingBackendConfigContent -import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.isConfigured -import com.wire.android.ui.authentication.login.LoginPasswordPath import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dialogs.FeatureDisabledWithProxyDialogContent @@ -92,14 +85,9 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDataDetailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreatePersonalAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateTeamAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.CustomTabsHelper import com.wire.kalium.logic.configuration.server.ServerConfig import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -109,22 +97,31 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan -@WireRootDestination( - style = PopUpNavigationAnimation::class, - navArgs = WelcomeNavArgs::class -) +internal sealed interface WelcomeScreenAction { + data class Login(val serverConfig: ServerConfig.Links) : WelcomeScreenAction + data class OpenUrl(val url: String) : WelcomeScreenAction + data class CreateTeam(val serverConfig: ServerConfig.Links) : WelcomeScreenAction + data class CreatePersonal(val serverConfig: ServerConfig.Links) : WelcomeScreenAction + data class CreateAccountData(val serverConfig: ServerConfig.Links) : WelcomeScreenAction +} + +/** + * Navigation-neutral screen adapter. It keeps rendering and dialog behavior in the screen while + * the owning runtime translates semantic actions into concrete routes. + */ @Composable -fun WelcomeScreen( - navigator: Navigator, - viewModel: WelcomeViewModel = welcomeViewModel() +internal fun WelcomeRouteScreen( + viewModel: WelcomeViewModel, + onNavigateBack: () -> Unit, + onAction: (WelcomeScreenAction) -> Unit, ) { WelcomeContent( viewModel.state.isThereActiveSession, viewModel.state.maxAccountsReached, viewModel.state.nomadAccountBlocksLogin, viewModel.state.links, - navigator::navigateBack, - navigator::navigate + onNavigateBack, + onAction, ) } @@ -135,7 +132,7 @@ private fun WelcomeContent( nomadAccountBlocksLogin: Boolean, state: ServerConfig.Links, navigateBack: () -> Unit, - navigate: (NavigationCommand) -> Unit + onAction: (WelcomeScreenAction) -> Unit, ) { WireScaffold(topBar = { if (isThereActiveSession) { @@ -196,12 +193,12 @@ private fun WelcomeContent( WelcomeCarousel(modifier = Modifier.weight(1f, true)) - WelcomeButtonsColumn(state = state, navigate = navigate) + WelcomeButtonsColumn(state = state, onAction = onAction) if (LocalCustomUiConfigurationProvider.current.isAccountCreationAllowed) { WelcomeFooterSection( state = state, - navigate = navigate, + onAction = onAction, modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.welcomeTextHorizontalPadding) ) } @@ -213,9 +210,8 @@ private fun WelcomeContent( @Composable private fun WelcomeButtonsColumn( state: ServerConfig.Links, - navigate: (NavigationCommand) -> Unit, + onAction: (WelcomeScreenAction) -> Unit, ) { - val context = LocalContext.current val teamCreationUrl = state.teams + stringResource(R.string.create_account_email_backlink_to_team_suffix_url) val enterpriseDisabledWithProxyDialogState = rememberVisibilityState() Column( @@ -229,12 +225,12 @@ private fun WelcomeButtonsColumn( } ) { LoginButton( - onClick = { navigate(NavigationCommand(LoginScreenDestination(loginPasswordPath = LoginPasswordPath(state)))) } + onClick = { onAction(WelcomeScreenAction.Login(state)) } ) FeatureDisabledWithProxyDialogContent( dialogState = enterpriseDisabledWithProxyDialogState, onActionButtonClicked = { - CustomTabsHelper.launchUrl(context, state.teams) + onAction(WelcomeScreenAction.OpenUrl(state.teams)) } ) if (LocalCustomUiConfigurationProvider.current.isAccountCreationAllowed) { @@ -248,9 +244,9 @@ private fun WelcomeButtonsColumn( ) } else { if (ENABLE_NEW_REGISTRATION) { - CustomTabsHelper.launchUrl(context, teamCreationUrl) + onAction(WelcomeScreenAction.OpenUrl(teamCreationUrl)) } else { - navigate(NavigationCommand(CreateTeamAccountOverviewScreenDestination(state))) + onAction(WelcomeScreenAction.CreateTeam(state)) } } } @@ -261,7 +257,7 @@ private fun WelcomeButtonsColumn( @Composable private fun WelcomeFooterSection( state: ServerConfig.Links, - navigate: (NavigationCommand) -> Unit, + onAction: (WelcomeScreenAction) -> Unit, modifier: Modifier = Modifier, ) { val createPersonalAccountDisabledWithProxyDialogState = rememberVisibilityState() @@ -277,17 +273,9 @@ private fun WelcomeFooterSection( ) } else { if (ENABLE_NEW_REGISTRATION) { - navigate( - NavigationCommand( - CreateAccountDataDetailScreenDestination( - CreateAccountDataNavArgs( - customServerConfig = state - ) - ) - ) - ) + onAction(WelcomeScreenAction.CreateAccountData(state)) } else { - navigate(NavigationCommand(CreatePersonalAccountOverviewScreenDestination(state))) + onAction(WelcomeScreenAction.CreatePersonal(state)) } } } @@ -471,7 +459,7 @@ fun PreviewWelcomeScreen() { nomadAccountBlocksLogin = false, state = ServerConfig.DEFAULT, navigateBack = {}, - navigate = {} + onAction = {} ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt index e68383d322b..1e21b3cf6d7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModel.kt @@ -21,11 +21,9 @@ package com.wire.android.ui.authentication.welcome import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.feature.session.DoesValidNomadAccountExistUseCase @@ -37,18 +35,15 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class WelcomeViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted navArgs: WelcomeNavArgs, private val getSessions: GetSessionsUseCase, private val doesValidNomadAccountExist: DoesValidNomadAccountExistUseCase, defaultServerConfig: ServerConfig.Links ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): WelcomeViewModel + fun create(navArgs: WelcomeNavArgs): WelcomeViewModel } - - private val navArgs: WelcomeNavArgs = savedStateHandle.navArgs() - var state by mutableStateOf(WelcomeScreenState(navArgs.customServerConfig ?: defaultServerConfig)) private set diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt index 5eb5a9b2bb6..d3d79d78139 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt @@ -32,36 +32,46 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.wire.android.appLogger -import com.wire.android.di.metro.LocalWireViewModelScopeKey -import com.wire.android.di.metro.AppSessionViewModelGraph -import com.wire.android.di.metro.createSessionViewModelGraph +import com.wire.android.di.metro.WireViewModelDiagnostics import com.wire.android.di.metro.wireApplicationGraph import com.wire.android.model.LocalWireSessionImageLoader +import com.wire.android.navigation.navigation3.rememberWireViewModelStoreProvider +import com.wire.android.navigation.navigation3.rememberWireSharedViewModelStoreOwner +import com.wire.android.navigation.navigation3.clearWireViewModelStoreOwner +import com.wire.android.navigation.runtime.wireSessionGraphStoreViewModel import com.wire.android.ui.AppLockActivity import com.wire.android.ui.BaseActivity import com.wire.android.ui.LocalActivity import com.wire.android.ui.calling.common.ProximitySensorManager +import com.wire.android.ui.common.commonTopAppBarViewModel import com.wire.android.ui.common.setupOrientationForDevice import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.CommonTopAppBarParams -import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel import com.wire.android.ui.common.topappbar.WireTopAppBar import com.wire.android.ui.theme.WireTheme import com.wire.android.util.SwitchAccountObserver import com.wire.kalium.logic.data.id.QualifiedIdMapper +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner +import com.wire.navigation.stableKey import dev.zacsweers.metro.HasMemberInjections import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Provider import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory -import kotlinx.coroutines.launch @HasMemberInjections abstract class CallActivity : BaseActivity() { @@ -75,6 +85,12 @@ abstract class CallActivity : BaseActivity() { @Inject lateinit var callActivityViewModelProvider: Provider + private val callActivityViewModel: CallActivityViewModel by viewModels { + viewModelFactory { + initializer { callActivityViewModelProvider() } + } + } + companion object { const val EXTRA_CONVERSATION_ID = "conversation_id" const val EXTRA_USER_ID = "user_id" @@ -83,94 +99,126 @@ abstract class CallActivity : BaseActivity() { const val TAG = "CallActivity" } - private val imageAssetViewModelGraph by lazy { wireApplicationGraph.imageAssetViewModelGraph } - private val commonTopAppBarViewModel: CommonTopAppBarViewModel by viewModels { - viewModelFactory { - initializer { - imageAssetViewModelGraph.commonTopAppBarViewModelFactory.create( - CommonTopAppBarParams(showNoNetwork = true, showSync = false, showActiveCalls = false) - ) - } - } - } - private val callActivityViewModel: CallActivityViewModel by viewModels { - viewModelFactory { - initializer { - callActivityViewModelProvider() - } - } - } - protected val qualifiedIdMapper = QualifiedIdMapper(null) + private var request: CallActivityRequest? by mutableStateOf(null) + private val qualifiedIdMapper = QualifiedIdMapper(null) + + protected abstract val destination: CallActivityDestination override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - handleNewIntent(intent) - setIntent(intent) + accept(intent) } override fun onCreate(savedInstanceState: Bundle?) { wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) - setupOrientationForDevice() - setUpScreenshotPreventionFlag() - setUpCallingFlags() - - enableEdgeToEdge() - val sessionViewModelGraph = createCallSessionViewModelGraph(intent) ?: run { - appLogger.e("$TAG missing call session user id, closing call activity") - finish() + if (!accept(intent)) { return } - handleNewIntent(intent) + setupOrientationForDevice() + setUpCallingFlags() + + enableEdgeToEdge() appLogger.i("$TAG Initializing proximity sensor..") proximitySensorManager.initialize() setContent { - val snackbarHostState = remember { SnackbarHostState() } - val rememberedSessionViewModelGraph = remember { sessionViewModelGraph } - CompositionLocalProvider( - LocalSnackbarHostState provides snackbarHostState, - LocalMetroViewModelFactory provides rememberedSessionViewModelGraph.metroViewModelFactory, - LocalWireViewModelScopeKey provides rememberedSessionViewModelGraph.viewModelScopeKey, - LocalWireSessionImageLoader provides rememberedSessionViewModelGraph.wireSessionImageLoader, - LocalActivity provides this - ) { - WireTheme { - Column( - modifier = Modifier.semantics { testTagsAsResourceId = true } - ) { - WireTopAppBar( - commonTopAppBarState = commonTopAppBarViewModel.state, - animateContentSize = false, // with animations, participant videos are blinking - ) - Box( - modifier = Modifier.consumeWindowInsets(WindowInsets.statusBars) - ) { - Content() - } - } - } - } + request?.let { CallActivityRoot(it) } } } - protected abstract fun handleNewIntent(intent: Intent) - @Composable - protected abstract fun Content() + protected abstract fun Content(request: CallActivityRequest) - private fun createCallSessionViewModelGraph(intent: Intent): AppSessionViewModelGraph? = - intent.getStringExtra(EXTRA_USER_ID) - ?.let(qualifiedIdMapper::fromStringToQualifiedID) - ?.let(wireApplicationGraph::createSessionViewModelGraph) + private fun accept(intent: Intent): Boolean { + val parsedRequest = intent.toCallActivityRequest(destination, qualifiedIdMapper) + if (parsedRequest == null) { + appLogger.e("$TAG invalid call intent for $destination, closing call activity") + finish() + return false + } + setIntent(intent) + request = parsedRequest + return true + } - fun switchAccountIfNeeded(userId: String?) { - userId?.let { - qualifiedIdMapper.fromStringToQualifiedID(it).run { - callActivityViewModel.switchAccountIfNeeded(userId = this, actions = switchAccountObserver) + @Composable + private fun CallActivityRoot(request: CallActivityRequest) { + val sessionId = WireSessionId(request.userId.value, request.userId.domain) + val ownerIdentity = WireViewModelOwner.Session(sessionId) + val ownerKey = ownerIdentity.stableKey() + val storeProvider = rememberWireViewModelStoreProvider(this) + val sessionGraphStore = wireSessionGraphStoreViewModel( + appGraph = wireApplicationGraph, + owner = this, + ) + key(ownerKey) { + val sessionGraphResult = remember(sessionId) { + runCatching { sessionGraphStore.graphFor(request.userId) } + } + val sessionGraph = sessionGraphResult.getOrNull() + if (sessionGraph == null) { + LaunchedEffect(sessionId) { + appLogger.e( + "$TAG unavailable call session, closing call activity", + sessionGraphResult.exceptionOrNull(), + ) + finish() + } + } else { + val sessionOwner = rememberWireSharedViewModelStoreOwner( + key = ownerKey, + provider = storeProvider, + ) + DisposableEffect(ownerIdentity, sessionOwner) { + WireViewModelDiagnostics.ownerAvailable(sessionOwner, ownerKey) + onDispose { + WireViewModelDiagnostics.ownerReleased(sessionOwner, ownerKey) + if (!isChangingConfigurations) { + clearWireViewModelStoreOwner(storeProvider, ownerKey) { + WireViewModelDiagnostics.ownerCleared(ownerKey) + } + sessionGraphStore.release(request.userId) + } + } + } + + CompositionLocalProvider( + LocalViewModelStoreOwner provides sessionOwner, + LocalMetroViewModelFactory provides sessionGraph.metroViewModelFactory, + LocalWireSessionImageLoader provides sessionGraph.wireSessionImageLoader, + LocalActivity provides this, + LocalSnackbarHostState provides remember { SnackbarHostState() }, + ) { + LaunchedEffect(callActivityViewModel, request.userId) { + setScreenshotPreventionFlag( + callActivityViewModel.isScreenshotCensoringConfigEnabled(request.userId).await() + ) + callActivityViewModel.switchAccountIfNeeded(request.userId, switchAccountObserver) + } + val commonTopAppBarViewModel = commonTopAppBarViewModel( + params = CommonTopAppBarParams( + showNoNetwork = true, + showSync = false, + showActiveCalls = false, + ), + owner = sessionOwner, + ) + WireTheme { + Column(modifier = Modifier.semantics { testTagsAsResourceId = true }) { + WireTopAppBar( + commonTopAppBarState = commonTopAppBarViewModel.state, + animateContentSize = false, + ) + Box(modifier = Modifier.consumeWindowInsets(WindowInsets.statusBars)) { + Content(request) + } + } + } + } } } } @@ -221,13 +269,11 @@ abstract class CallActivity : BaseActivity() { } } - fun setUpScreenshotPreventionFlag() { - lifecycleScope.launch { - if (callActivityViewModel.isScreenshotCensoringConfigEnabled().await()) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } + private fun setScreenshotPreventionFlag(enabled: Boolean) { + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityRouting.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityRouting.kt new file mode 100644 index 00000000000..1aaf6ce69b8 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityRouting.kt @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.calling + +import android.content.Intent +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedIdMapper +import com.wire.kalium.logic.data.user.UserId + +/** Immutable input consumed by the call Activity composition root. */ +data class CallActivityRequest( + val userId: UserId, + val conversationId: ConversationId, + val screen: CallActivityScreen, + val shouldAnswerCall: Boolean, +) + +sealed interface CallActivityScreen { + data class Starting(val type: StartingCallScreenType) : CallActivityScreen + data object Ongoing : CallActivityScreen +} + +enum class CallActivityDestination { + STARTING, + ONGOING, +} + +/** + * The only parser for call routing intents. + * + * Both initial launch and [android.app.Activity.onNewIntent] go through this boundary, so a + * malformed replacement intent can never partially update an existing call session. + */ +internal fun Intent.toCallActivityRequest( + destination: CallActivityDestination, + qualifiedIdMapper: QualifiedIdMapper, +): CallActivityRequest? { + val userId = getStringExtra(CallActivity.EXTRA_USER_ID) + val conversationId = getStringExtra(CallActivity.EXTRA_CONVERSATION_ID) + val screen = when (destination) { + CallActivityDestination.STARTING -> getStringExtra(CallActivity.EXTRA_SCREEN_TYPE) + ?.let(StartingCallScreenType::byName) + ?.let(CallActivityScreen::Starting) + + CallActivityDestination.ONGOING -> CallActivityScreen.Ongoing + } + + return if (userId == null || conversationId == null || screen == null) { + null + } else { + runCatching { + CallActivityRequest( + userId = qualifiedIdMapper.fromStringToQualifiedID(userId), + conversationId = qualifiedIdMapper.fromStringToQualifiedID(conversationId), + screen = screen, + shouldAnswerCall = getBooleanExtra(CallActivity.EXTRA_SHOULD_ANSWER_CALL, false), + ) + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt index e98f5f7fc6d..0a87dbe7929 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt @@ -43,18 +43,12 @@ class CallActivityViewModel @Inject constructor( private val accountSwitch: AccountSwitchUseCase ) : ViewModel() { - fun isScreenshotCensoringConfigEnabled(): Deferred = + fun isScreenshotCensoringConfigEnabled(userId: UserId): Deferred = viewModelScope.async(dispatchers.io()) { - val currentSession = currentSession() - if (currentSession is CurrentSessionResult.Success) { - return@async observeScreenshotCensoringConfigUseCaseProviderFactory.create( - currentSession.accountInfo.userId - ).observeScreenshotCensoringConfig().map { - it is ObserveScreenshotCensoringConfigResult.Enabled - }.first() - } else { - return@async false - } + observeScreenshotCensoringConfigUseCaseProviderFactory.create(userId) + .observeScreenshotCensoringConfig() + .map { it is ObserveScreenshotCensoringConfigResult.Enabled } + .first() } fun switchAccountIfNeeded(userId: UserId, actions: SwitchAccountActions) { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallingManualViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallingManualViewModelFactory.kt index 70869026eee..106b773f2e9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallingManualViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallingManualViewModelFactory.kt @@ -19,13 +19,14 @@ package com.wire.android.ui.calling import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalInspectionMode -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.ui.calling.common.SharedCallingViewModel import com.wire.android.ui.calling.incoming.IncomingCallViewModel import com.wire.android.ui.calling.ongoing.OngoingCallViewModel import com.wire.android.ui.calling.outgoing.OutgoingCallViewModel import com.wire.android.ui.home.conversations.call.ConversationCallViewModel +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversationslist.ConversationListCallViewModel import com.wire.android.ui.home.conversationslist.ConversationListCallViewModelImpl import com.wire.android.ui.home.conversationslist.ConversationListCallViewModelPreview @@ -33,56 +34,62 @@ import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.meetings.MeetingsCallViewModel import com.wire.kalium.logic.data.id.ConversationId import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory -import dev.zacsweers.metrox.viewmodel.metroViewModel as metroxViewModel interface CallingManualViewModelFactory : ManualViewModelAssistedFactory { fun incomingCallViewModel(conversationId: ConversationId): IncomingCallViewModel fun outgoingCallViewModel(conversationId: ConversationId): OutgoingCallViewModel fun ongoingCallViewModel(conversationId: ConversationId): OngoingCallViewModel fun sharedCallingViewModel(conversationId: ConversationId): SharedCallingViewModel + fun conversationCallViewModel(args: ConversationNavArgs): ConversationCallViewModel } @Composable fun incomingCallViewModel(conversationId: ConversationId): IncomingCallViewModel = - sessionKeyedAssistedMetroViewModel( - key = "incoming_$conversationId", - ) { + wireAssistedMetroViewModel( + instanceKey = "incoming_$conversationId", + ) { _ -> incomingCallViewModel(conversationId) } @Composable fun outgoingCallViewModel(conversationId: ConversationId): OutgoingCallViewModel = - sessionKeyedAssistedMetroViewModel( - key = "outgoing_$conversationId", - ) { + wireAssistedMetroViewModel( + instanceKey = "outgoing_$conversationId", + ) { _ -> outgoingCallViewModel(conversationId) } @Composable fun ongoingCallViewModel(conversationId: ConversationId): OngoingCallViewModel = - sessionKeyedAssistedMetroViewModel( - key = "ongoing_$conversationId", - ) { + wireAssistedMetroViewModel( + instanceKey = "ongoing_$conversationId", + ) { _ -> ongoingCallViewModel(conversationId) } @Composable fun sharedCallingViewModel(conversationId: ConversationId): SharedCallingViewModel = - sessionKeyedAssistedMetroViewModel( - key = "shared_$conversationId", - ) { + wireAssistedMetroViewModel( + instanceKey = "shared_$conversationId", + ) { _ -> sharedCallingViewModel(conversationId) } @Composable fun conversationCallViewModel(): ConversationCallViewModel = - metroxViewModel() + wireMetroViewModel() + +@Composable +fun conversationCallViewModel(args: ConversationNavArgs): ConversationCallViewModel = + wireAssistedMetroViewModel { _ -> + conversationCallViewModel(args) + } @Composable fun conversationListCallViewModel(conversationsSource: ConversationsSource): ConversationListCallViewModel = when { LocalInspectionMode.current -> ConversationListCallViewModelPreview - else -> sessionKeyedMetroViewModel(key = "call_$conversationsSource") + else -> wireMetroViewModel(instanceKey = "call_$conversationsSource") } @Composable -fun meetingsCallViewModel(): MeetingsCallViewModel = sessionKeyedMetroViewModel() +fun meetingsCallViewModel(): MeetingsCallViewModel = wireMetroViewModel() diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallingMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallingMetroViewModelBindings.kt new file mode 100644 index 00000000000..8f83141029a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallingMetroViewModelBindings.kt @@ -0,0 +1,81 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling + +import androidx.lifecycle.ViewModel +import com.wire.android.ui.CallFeedbackViewModel +import com.wire.android.ui.calling.common.SharedCallingViewModel +import com.wire.android.ui.calling.incoming.IncomingCallViewModel +import com.wire.android.ui.calling.ongoing.OngoingCallViewModel +import com.wire.android.ui.calling.outgoing.OutgoingCallViewModel +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.call.ConversationCallViewModel +import com.wire.android.ui.home.conversationslist.ConversationListCallViewModelImpl +import com.wire.android.ui.home.meetings.MeetingsCallViewModel +import com.wire.kalium.logic.data.id.ConversationId +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +@BindingContainer +object CallingMetroViewModelBindings { + + @Provides + @IntoMap + @ViewModelKey(CallFeedbackViewModel::class) + fun callFeedbackViewModel(viewModel: CallFeedbackViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ViewModelKey(MeetingsCallViewModel::class) + fun meetingsCallViewModel(viewModel: MeetingsCallViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ViewModelKey(ConversationListCallViewModelImpl::class) + fun conversationListCallViewModel(viewModel: ConversationListCallViewModelImpl): ViewModel = viewModel + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(CallingManualViewModelFactory::class) + fun callingManualViewModelFactory( + incomingCallFactory: IncomingCallViewModel.Factory, + outgoingCallFactory: OutgoingCallViewModel.Factory, + ongoingCallFactory: OngoingCallViewModel.Factory, + sharedCallingFactory: SharedCallingViewModel.Factory, + conversationCallFactory: ConversationCallViewModel.Factory, + ): ManualViewModelAssistedFactory = object : CallingManualViewModelFactory { + override fun incomingCallViewModel(conversationId: ConversationId): IncomingCallViewModel = + incomingCallFactory.create(conversationId) + + override fun outgoingCallViewModel(conversationId: ConversationId): OutgoingCallViewModel = + outgoingCallFactory.create(conversationId) + + override fun ongoingCallViewModel(conversationId: ConversationId): OngoingCallViewModel = + ongoingCallFactory.create(conversationId) + + override fun sharedCallingViewModel(conversationId: ConversationId): SharedCallingViewModel = + sharedCallingFactory.create(conversationId) + + override fun conversationCallViewModel(args: ConversationNavArgs): ConversationCallViewModel = + conversationCallFactory.create(args) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt index 1c947c4c602..e05ac9365df 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt @@ -22,9 +22,6 @@ import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics @@ -49,65 +46,52 @@ import com.wire.android.ui.calling.outgoing.OutgoingCallScreen */ @OptIn(ExperimentalComposeUiApi::class) class StartingCallActivity : CallActivity() { - private var conversationId: String? by mutableStateOf(null) - private var userId: String? by mutableStateOf(null) - private var screenType: StartingCallScreenType? by mutableStateOf(null) - private var shouldAnswerCall: Boolean by mutableStateOf(false) - - override fun handleNewIntent(intent: Intent) { - conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) - userId = intent.extras?.getString(EXTRA_USER_ID) - screenType = intent.extras?.getString(EXTRA_SCREEN_TYPE)?.let { StartingCallScreenType.byName(it) } - shouldAnswerCall = intent.extras?.getBoolean(EXTRA_SHOULD_ANSWER_CALL, false) ?: false - require(conversationId != null) { "$TAG No conversation ID provided in intent extras" } - require(userId != null) { "$TAG No user ID provided in intent extras" } - require(screenType != null) { "$TAG No screen type provided in intent extras" } - switchAccountIfNeeded(userId) - } + override val destination: CallActivityDestination = CallActivityDestination.STARTING @Composable - override fun Content() { - screenType?.let { currentScreenType -> - AnimatedContent( - targetState = currentScreenType, - transitionSpec = { - TransitionAnimationType.POP_UP.enterTransition.togetherWith( - TransitionAnimationType.POP_UP.exitTransition - ) - }, - modifier = Modifier.semantics { testTagsAsResourceId = true }, - label = currentScreenType.name - ) { screenType -> - conversationId?.let { conversationId -> - userId?.let { userId -> - when (screenType) { - StartingCallScreenType.Outgoing -> { - OutgoingCallScreen( - conversationId = qualifiedIdMapper.fromStringToQualifiedID(conversationId) - ) { - getOngoingCallIntent(this@StartingCallActivity, conversationId, userId).run { - this@StartingCallActivity.startActivity(this) - } - this@StartingCallActivity.finishAndRemoveTask() - } - } - - StartingCallScreenType.Incoming -> { - IncomingCallScreen( - conversationId = qualifiedIdMapper.fromStringToQualifiedID(conversationId), - shouldTryToAnswerCallAutomatically = shouldAnswerCall, - ) { - this@StartingCallActivity.startActivity( - getOngoingCallIntent(this@StartingCallActivity, conversationId, userId) - ) - this@StartingCallActivity.finishAndRemoveTask() - } - } + override fun Content(request: CallActivityRequest) { + val screen = (request.screen as? CallActivityScreen.Starting)?.type ?: return + AnimatedContent( + targetState = screen, + transitionSpec = { + TransitionAnimationType.POP_UP.enterTransition.togetherWith( + TransitionAnimationType.POP_UP.exitTransition + ) + }, + modifier = Modifier.semantics { testTagsAsResourceId = true }, + label = screen.name + ) { screenType -> + when (screenType) { + StartingCallScreenType.Outgoing -> { + OutgoingCallScreen(conversationId = request.conversationId) { + getOngoingCallIntent( + this@StartingCallActivity, + request.conversationId.toString(), + request.userId.toString(), + ).run { + this@StartingCallActivity.startActivity(this) } + this@StartingCallActivity.finishAndRemoveTask() + } + } + + StartingCallScreenType.Incoming -> { + IncomingCallScreen( + conversationId = request.conversationId, + shouldTryToAnswerCallAutomatically = request.shouldAnswerCall, + ) { + this@StartingCallActivity.startActivity( + getOngoingCallIntent( + this@StartingCallActivity, + request.conversationId.toString(), + request.userId.toString(), + ) + ) + this@StartingCallActivity.finishAndRemoveTask() } } } - } ?: run { finish() } + } } override fun onResume() { @@ -124,10 +108,6 @@ class StartingCallActivity : CallActivity() { cleanUpCallingFlags() super.onDestroy() } - - companion object { - private const val TAG = "StartingCallActivity" - } } fun getOutgoingCallIntent( diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt index 5424860d77e..46af351432c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt @@ -17,16 +17,13 @@ */ package com.wire.android.ui.calling.ongoing -import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.os.Bundle import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics @@ -36,6 +33,8 @@ import com.wire.android.navigation.style.TransitionAnimationType import com.wire.android.notification.CallNotificationManager import com.wire.android.services.ServicesManager import com.wire.android.ui.calling.CallActivity +import com.wire.android.ui.calling.CallActivityDestination +import com.wire.android.ui.calling.CallActivityRequest import com.wire.android.ui.calling.CallActivity.Companion.EXTRA_CONVERSATION_ID import com.wire.android.ui.calling.CallActivity.Companion.EXTRA_SHOULD_ANSWER_CALL import com.wire.android.ui.calling.CallActivity.Companion.EXTRA_USER_ID @@ -58,54 +57,36 @@ class OngoingCallActivity : CallActivity() { @Inject lateinit var callNotificationManager: CallNotificationManager - var conversationId: String? by mutableStateOf(null) - var userId: String? by mutableStateOf(null) - private var shouldAnswerCall: Boolean by mutableStateOf(false) + override val destination: CallActivityDestination = CallActivityDestination.ONGOING - override fun handleNewIntent(intent: Intent) { - conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) - userId = intent.extras?.getString(EXTRA_USER_ID) - shouldAnswerCall = intent.extras?.getBoolean(EXTRA_SHOULD_ANSWER_CALL) ?: false - require(conversationId != null) { "$TAG No conversation ID provided in intent extras" } - require(userId != null) { "$TAG No user ID provided in intent extras" } - switchAccountIfNeeded(userId) - } - - @SuppressLint("UnusedContentLambdaTargetStateParameter") override fun onCreate(savedInstanceState: Bundle?) { wireApplicationGraph.inject(this) super.onCreate(savedInstanceState) - - if (shouldAnswerCall && userId != null && conversationId != null) { - callNotificationManager.hideIncomingCallNotification(userId!!, conversationId!!) - servicesManager.startCallServiceToAnswer( - qualifiedIdMapper.fromStringToQualifiedID(userId!!), - qualifiedIdMapper.fromStringToQualifiedID(conversationId!!), - ) - } } - @SuppressLint("UnusedContentLambdaTargetStateParameter") @Composable - override fun Content() { - conversationId?.let { conversationId -> - AnimatedContent( - targetState = TAG, - transitionSpec = { - TransitionAnimationType.POP_UP.enterTransition.togetherWith( - TransitionAnimationType.POP_UP.exitTransition - ) - }, - modifier = Modifier.semantics { testTagsAsResourceId = true }, - label = TAG - ) { _ -> - OngoingCallScreen( - qualifiedIdMapper.fromStringToQualifiedID( - conversationId - ) + override fun Content(request: CallActivityRequest) { + LaunchedEffect(request) { + if (request.shouldAnswerCall) { + callNotificationManager.hideIncomingCallNotification( + request.userId.toString(), + request.conversationId.toString(), ) + servicesManager.startCallServiceToAnswer(request.userId, request.conversationId) } - } ?: run { finish() } + } + AnimatedContent( + targetState = request.conversationId, + transitionSpec = { + TransitionAnimationType.POP_UP.enterTransition.togetherWith( + TransitionAnimationType.POP_UP.exitTransition + ) + }, + modifier = Modifier.semantics { testTagsAsResourceId = true }, + label = TAG + ) { conversationId -> + OngoingCallScreen(conversationId) + } } override fun onResume() { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/CommonMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/ui/common/CommonMetroViewModelBindings.kt new file mode 100644 index 00000000000..8b3a6e02e70 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/CommonMetroViewModelBindings.kt @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common + +import androidx.lifecycle.ViewModel +import com.wire.android.ui.common.banner.SecurityClassificationArgs +import com.wire.android.ui.common.banner.SecurityClassificationViewModelImpl +import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionsMenuViewModelImpl +import com.wire.android.ui.common.topappbar.CommonTopAppBarParams +import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +@BindingContainer +object CommonMetroViewModelBindings { + + @Provides + @IntoMap + @ViewModelKey(ConversationOptionsMenuViewModelImpl::class) + fun conversationOptionsMenuViewModel(viewModel: ConversationOptionsMenuViewModelImpl): ViewModel = viewModel + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(CommonManualViewModelFactory::class) + internal fun commonManualViewModelFactory( + commonTopAppBarFactory: CommonTopAppBarViewModel.Factory, + securityClassificationFactory: SecurityClassificationViewModelImpl.Factory, + ): ManualViewModelAssistedFactory = object : CommonManualViewModelFactory { + override fun commonTopAppBarViewModel(params: CommonTopAppBarParams): CommonTopAppBarViewModel = + commonTopAppBarFactory.create(params) + + override fun securityClassificationViewModel(args: SecurityClassificationArgs): SecurityClassificationViewModelImpl = + securityClassificationFactory.create(args) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/CommonViewModelFactory.kt b/app/src/main/kotlin/com/wire/android/ui/common/CommonViewModelGraph.kt similarity index 62% rename from app/src/main/kotlin/com/wire/android/ui/common/CommonViewModelFactory.kt rename to app/src/main/kotlin/com/wire/android/ui/common/CommonViewModelGraph.kt index 9693aae9f0e..04d09083fab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/CommonViewModelFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/CommonViewModelGraph.kt @@ -15,52 +15,57 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("MatchingDeclarationName") + package com.wire.android.ui.common import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.wire.android.di.ViewModelScopedPreviews -import com.wire.android.di.wireManualMetroViewModelScoped +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModelAs +import com.wire.android.di.metro.wireMetroViewModelAs import com.wire.android.ui.common.banner.SecurityClassificationArgs import com.wire.android.ui.common.banner.SecurityClassificationViewModel import com.wire.android.ui.common.banner.SecurityClassificationViewModelImpl import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionsMenuViewModel import com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionsMenuViewModelImpl +import com.wire.android.ui.common.topappbar.CommonTopAppBarParams import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory internal interface CommonManualViewModelFactory : ManualViewModelAssistedFactory { + fun commonTopAppBarViewModel(params: CommonTopAppBarParams): CommonTopAppBarViewModel fun securityClassificationViewModel(args: SecurityClassificationArgs): SecurityClassificationViewModelImpl - fun conversationOptionsMenuViewModel(): ConversationOptionsMenuViewModelImpl } -interface CommonViewModelGraph { - val commonTopAppBarViewModelFactory: CommonTopAppBarViewModel.Factory -} +@Composable +fun commonTopAppBarViewModel( + params: CommonTopAppBarParams, + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current), +): CommonTopAppBarViewModel = + wireAssistedMetroViewModel(owner = owner) { _ -> + commonTopAppBarViewModel(params) + } @Composable fun securityClassificationViewModel( args: SecurityClassificationArgs, ): SecurityClassificationViewModel = - wireManualMetroViewModelScoped< - SecurityClassificationViewModelImpl, - SecurityClassificationViewModel, - SecurityClassificationArgs, - CommonManualViewModelFactory - >( - arguments = args, + wireAssistedMetroViewModelAs< + SecurityClassificationViewModelImpl, + SecurityClassificationViewModel, + CommonManualViewModelFactory, + >( + instanceKey = args.key, previewProvider = ViewModelScopedPreviews, - ) { _, arguments -> - securityClassificationViewModel(arguments) + ) { _ -> + securityClassificationViewModel(args) } @Composable fun conversationOptionsMenuViewModel(): ConversationOptionsMenuViewModel = - wireManualMetroViewModelScoped< - ConversationOptionsMenuViewModelImpl, - ConversationOptionsMenuViewModel, - CommonManualViewModelFactory, - >( + wireMetroViewModelAs( previewProvider = ViewModelScopedPreviews, - ) { - conversationOptionsMenuViewModel() - } + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationArgs.kt b/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationArgs.kt index 5f260f4351f..57ba7748df8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationArgs.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.common.banner -import com.wire.android.di.ScopedArgs import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId @@ -25,9 +24,10 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -sealed interface SecurityClassificationArgs : ScopedArgs { +sealed interface SecurityClassificationArgs { val id: QualifiedID + val key: String @Serializable data class Conversation( diff --git a/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationViewModel.kt index c0297419f6d..8953dd5a6eb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationViewModel.kt @@ -27,15 +27,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.android.di.ViewModelScopedPreview import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.SecurityClassificationType -import com.wire.kalium.logic.feature.session.CurrentSessionResult -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @ViewModelScopedPreview interface SecurityClassificationViewModel { @@ -44,7 +42,8 @@ interface SecurityClassificationViewModel { class SecurityClassificationViewModelImpl @AssistedInject constructor( @KaliumCoreLogic private val coreLogic: CoreLogic, - @Assisted private val args: SecurityClassificationArgs + @CurrentAccount private val currentAccount: UserId, + @Assisted private val args: SecurityClassificationArgs, ) : SecurityClassificationViewModel, ViewModel() { @AssistedFactory interface Factory { @@ -57,24 +56,10 @@ class SecurityClassificationViewModelImpl @AssistedInject constructor( init { viewModelScope.launch { - coreLogic.getGlobalScope().session.currentSessionFlow() - .flatMapLatest { currentSession -> - when (currentSession) { - is CurrentSessionResult.Success -> when (args) { - is SecurityClassificationArgs.Conversation -> observeConversationClassificationType( - currentSession.accountInfo.userId, - args.id - ) - - is SecurityClassificationArgs.User -> observeUserClassificationType( - currentSession.accountInfo.userId, - args.id - ) - } - - is CurrentSessionResult.Failure -> flowOf(SecurityClassificationType.NONE) - } - } + when (args) { + is SecurityClassificationArgs.Conversation -> observeConversationClassificationType(currentAccount, args.id) + is SecurityClassificationArgs.User -> observeUserClassificationType(currentAccount, args.id) + } .collect { classificationType -> state = classificationType } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt index fd2fd436d6a..f5e386c1759 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger +import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager @@ -39,7 +40,6 @@ import com.wire.kalium.logic.data.sync.SyncState.GatheringPendingEvents import com.wire.kalium.logic.data.sync.SyncState.SlowSync import com.wire.kalium.logic.data.sync.SyncState.Waiting import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.network.NetworkState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -47,14 +47,14 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch import org.jetbrains.annotations.VisibleForTesting +import kotlinx.coroutines.launch class CommonTopAppBarViewModel @AssistedInject constructor( private val currentScreenManager: CurrentScreenManager, @KaliumCoreLogic private val coreLogic: Lazy, + @CurrentAccount private val currentAccount: UserId, @Assisted private val params: CommonTopAppBarParams, ) : ViewModel() { @AssistedFactory @@ -130,45 +130,23 @@ class CommonTopAppBarViewModel @AssistedInject constructor( init { viewModelScope.launch { - coreLogic.value.globalScope { - session.currentSessionFlow() - .flatMapLatest { - when (it) { - is CurrentSessionResult.Failure.Generic, - is CurrentSessionResult.Failure.SessionNotFound -> flowOf( - ConnectivityUIState.None - ) - - is CurrentSessionResult.Success -> { - val userId = it.accountInfo.userId - combine( - activeCallsFlow(userId), - currentScreenFlow(), - connectivityFlow(userId), - ) { activeCalls, currentScreen, connectivity -> - mapToConnectivityUIState(currentScreen, connectivity, userId, activeCalls) - }.debounce { state -> - // Scoped inside flatMapLatest so this debounce is canceled - // together with the inner flow on session change, preventing - // stale state from leaking into a new session. - when { - // Delay the ongoing-call bar slightly to absorb rapid - // mute/unmute state changes without flickering. - state is ConnectivityUIState.Calls && state.hasOngoingCall -> - CONNECTIVITY_STATE_DEBOUNCE_ONGOING_CALL - // Everything else (connectivity banners, incoming/outgoing - // calls, None) passes through immediately. Connectivity - // states are already debounced inside connectivityFlow. - else -> 0L - } - } - } - } - } - .collectLatest { connectivityUIState -> - state = state.copy(connectivityState = connectivityUIState) - } + combine( + activeCallsFlow(currentAccount), + currentScreenFlow(), + connectivityFlow(currentAccount), + ) { activeCalls, currentScreen, connectivity -> + mapToConnectivityUIState(currentScreen, connectivity, currentAccount, activeCalls) + }.debounce { state -> + when { + state is ConnectivityUIState.Calls && state.hasOngoingCall -> + CONNECTIVITY_STATE_DEBOUNCE_ONGOING_CALL + else -> 0L + } + }.collectLatest { connectivityUIState -> + state = state.copy(connectivityState = connectivityUIState) } + } + viewModelScope.launch { coreLogic.value.networkStateObserver.observeNetworkState().collectLatest { state = state.copy(networkState = it) } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index 831a1f33d18..f71e0645597 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -57,6 +57,7 @@ import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -66,7 +67,6 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.days -import dev.zacsweers.metro.Inject @Suppress("TooManyFunctions") @ViewModelScopedPreview diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugInfoViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugInfoViewModelGraph.kt index 547d338e233..a7ad1fa83fd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugInfoViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugInfoViewModelGraph.kt @@ -21,7 +21,10 @@ import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.MetroViewModelGraph +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel +import com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs import com.wire.android.ui.debug.conversation.DebugConversationViewModel import com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsViewModel import com.wire.android.ui.debug.featureflags.DebugFeatureFlagsViewModel @@ -29,6 +32,13 @@ import com.wire.android.ui.home.settings.about.dependencies.DependenciesViewMode import com.wire.android.ui.home.settings.about.licenses.LicensesViewModel import com.wire.android.ui.home.whatsnew.WhatsNewViewModel import com.wire.android.ui.settings.about.AboutThisAppViewModel +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory + +interface DebugInfoViewModelGraph : MetroViewModelGraph + +interface DebugInfoManualViewModelFactory : ManualViewModelAssistedFactory { + fun debugConversationViewModel(args: DebugConversationScreenNavArgs): DebugConversationViewModel +} @Composable inline fun debugInfoViewModel( @@ -37,9 +47,9 @@ inline fun debugInfoViewModel( }, key: String? = null, ): VM where VM : ViewModel = - sessionKeyedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = key, + wireMetroViewModel( + owner = viewModelStoreOwner, + instanceKey = key, ) @Composable @@ -59,8 +69,10 @@ fun exportObfuscatedCopyViewModel(): ExportObfuscatedCopyViewModel = debugInfoViewModel() @Composable -fun debugConversationViewModel(): DebugConversationViewModel = - debugInfoViewModel() +fun debugConversationViewModel(args: DebugConversationScreenNavArgs): DebugConversationViewModel = + wireAssistedMetroViewModel { _ -> + debugConversationViewModel(args) + } @Composable fun conversationCryptoStatsViewModel(): ConversationCryptoStatsViewModel = diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindings.kt new file mode 100644 index 00000000000..63255a2783c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindings.kt @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.debug + +import androidx.lifecycle.ViewModel +import com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs +import com.wire.android.ui.debug.conversation.DebugConversationViewModel +import com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsViewModel +import com.wire.android.ui.debug.featureflags.DebugFeatureFlagsViewModel +import com.wire.android.ui.home.settings.about.dependencies.DependenciesViewModel +import com.wire.android.ui.home.settings.about.licenses.LicensesViewModel +import com.wire.android.ui.home.whatsnew.WhatsNewViewModel +import com.wire.android.ui.settings.about.AboutThisAppViewModel +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +@BindingContainer +object DebugMetroViewModelBindings { + + @Provides @IntoMap @ViewModelKey(UserDebugViewModel::class) + fun userDebugViewModel(viewModel: UserDebugViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(LogManagementViewModel::class) + fun logManagementViewModel(viewModel: LogManagementViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(DebugDataOptionsViewModelImpl::class) + fun debugDataOptionsViewModel(viewModel: DebugDataOptionsViewModelImpl): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(ExportObfuscatedCopyViewModelImpl::class) + fun exportObfuscatedCopyViewModel(viewModel: ExportObfuscatedCopyViewModelImpl): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(ConversationCryptoStatsViewModel::class) + fun conversationCryptoStatsViewModel(viewModel: ConversationCryptoStatsViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(DebugFeatureFlagsViewModel::class) + fun debugFeatureFlagsViewModel(viewModel: DebugFeatureFlagsViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(WhatsNewViewModel::class) + fun whatsNewViewModel(viewModel: WhatsNewViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(AboutThisAppViewModel::class) + fun aboutThisAppViewModel(viewModel: AboutThisAppViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(DependenciesViewModel::class) + fun dependenciesViewModel(viewModel: DependenciesViewModel): ViewModel = viewModel + + @Provides @IntoMap @ViewModelKey(LicensesViewModel::class) + fun licensesViewModel(viewModel: LicensesViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(DebugInfoManualViewModelFactory::class) + internal fun debugInfoManualViewModelFactory( + factory: DebugConversationViewModel.Factory, + ): ManualViewModelAssistedFactory = object : DebugInfoManualViewModelFactory { + override fun debugConversationViewModel(args: DebugConversationScreenNavArgs): DebugConversationViewModel = + factory.create(args) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index 987027dd6e4..356511dfee9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -43,22 +43,14 @@ import androidx.compose.ui.tooling.preview.Preview import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.ConversationCryptoStatsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DebugFeatureFlagsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.home.settings.backup.BackupAndRestoreDialog import com.wire.android.ui.home.settings.backup.rememberBackUpAndRestoreStateHolder -import com.wire.android.ui.sharing.ImportMediaNavArgs -import com.wire.android.ui.sharing.ImportSource import com.wire.android.ui.theme.WireTheme import com.wire.android.util.AppNameUtil import com.wire.android.util.logging.LogShareLauncher @@ -66,50 +58,37 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import java.io.File -@WireRootDestination @Composable -fun DebugScreen( - navigator: Navigator, +internal fun DebugRouteScreen( + onBack: () -> Unit, + onShowFeatureFlags: () -> Unit, + onShowCryptoStats: () -> Unit, + onShareLogsViaWire: (Uri) -> Unit, userDebugViewModel: UserDebugViewModel = userDebugViewModel(), debugDataOptionsViewModel: DebugDataOptionsViewModel = debugDataOptionsViewModel(), exportObfuscatedCopyViewModel: ExportObfuscatedCopyViewModel = exportObfuscatedCopyViewModel(), ) { UserDebugContent( - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onBack, state = userDebugViewModel.state, onLoggingEnabledChange = userDebugViewModel::setLoggingEnabledState, onDeleteLogs = userDebugViewModel::deleteLogs, onFlushLogs = userDebugViewModel::flushLogs, + onShareLogsViaWire = onShareLogsViaWire, onDatabaseLoggerEnabledChanged = userDebugViewModel::setDatabaseLoggerEnabledState, debugDataOptionsContent = { debugContentState -> DebugDataOptions( appVersion = AppNameUtil.createAppName(), buildVariant = "${BuildConfig.FLAVOR}${BuildConfig.BUILD_TYPE.replaceFirstChar { it.uppercase() }}", onCopyText = debugContentState::copyToClipboard, - onShowFeatureFlags = { - navigator.navigate(NavigationCommand(DebugFeatureFlagsScreenDestination)) - }, - onShowCryptoStats = { - navigator.navigate(NavigationCommand(ConversationCryptoStatsScreenDestination)) - }, + onShowFeatureFlags = onShowFeatureFlags, + onShowCryptoStats = onShowCryptoStats, viewModel = debugDataOptionsViewModel, ) }, dangerOptionsContent = { DangerOptions(exportObfuscatedCopyViewModel = exportObfuscatedCopyViewModel) }, - onShareLogsViaWire = { uri -> - navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination( - ImportMediaNavArgs( - source = ImportSource.INTERNAL_SHARE, - internalAssetUriList = arrayListOf(uri) - ) - ) - ) - ) - }, ) } @@ -261,11 +240,7 @@ data class DebugContentState( fun shareLogsViaWire(onFlushLogs: () -> Deferred, onShareUri: (Uri) -> Unit) { val dir = File(logPath).parentFile if (dir != null && dir.exists()) { - logShareLauncher.shareLogsViaWire( - logsDirectory = dir, - onShareUri = onShareUri - ) { - // Flush any buffered logs before sharing to ensure completeness. + logShareLauncher.shareLogsViaWire(dir, onShareUri) { onFlushLogs().await() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/ExportObfuscatedCopyViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/ExportObfuscatedCopyViewModel.kt index 85a3b638a14..020a4d4f553 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/ExportObfuscatedCopyViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/ExportObfuscatedCopyViewModel.kt @@ -35,12 +35,12 @@ import com.wire.android.ui.home.settings.backup.BackupCreationProgress import com.wire.android.util.FileManager import com.wire.android.util.dispatchers.DefaultDispatcherProvider import com.wire.android.util.dispatchers.DispatcherProvider +import dev.zacsweers.metro.Inject import com.wire.kalium.logic.feature.backup.CreateBackupResult import com.wire.kalium.logic.feature.backup.CreateObfuscatedCopyUseCase import com.wire.kalium.util.DelicateKaliumApi import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import dev.zacsweers.metro.Inject @ViewModelScopedPreview interface ExportObfuscatedCopyViewModel { @@ -55,7 +55,7 @@ interface ExportObfuscatedCopyViewModel { fun cancelBackupCreation() {} } -class ExportObfuscatedCopyViewModelImpl @Inject @OptIn(DelicateKaliumApi::class) constructor( +class ExportObfuscatedCopyViewModelImpl @OptIn(DelicateKaliumApi::class) @Inject constructor( private val createUnencryptedCopy: CreateObfuscatedCopyUseCase, private val dispatcher: DispatcherProvider = DefaultDispatcherProvider(), private val fileManager: FileManager, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt index 4661a563451..6f64e82e38f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementScreen.kt @@ -17,8 +17,7 @@ */ package com.wire.android.ui.debug -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination -import com.wire.android.navigation.annotation.app.WireRootDestination +import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -27,21 +26,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.wire.android.ui.sharing.ImportMediaNavArgs -import com.wire.android.ui.sharing.ImportSource -@WireRootDestination @Composable -fun LogManagementScreen( - navigator: Navigator, +internal fun LogManagementRouteScreen( + onBack: () -> Unit, + onShareLogsViaWire: (Uri) -> Unit, modifier: Modifier = Modifier, - viewModel: LogManagementViewModel = logManagementViewModel() + viewModel: LogManagementViewModel = logManagementViewModel(), ) { val state = viewModel.state val contentState = rememberDebugContentState(state.logPath) @@ -53,7 +48,7 @@ fun LogManagementScreen( title = stringResource(R.string.label_logs_option_title), elevation = dimensions().spacing0x, navigationIconType = NavigationIconType.Close(), - onNavigationPressed = navigator::navigateBack + onNavigationPressed = onBack ) } ) { internalPadding -> @@ -68,20 +63,7 @@ fun LogManagementScreen( onLoggingEnabledChange = viewModel::setLoggingEnabledState, onDeleteLogs = viewModel::deleteLogs, onShareLogsExternally = { contentState.shareLogsExternally(viewModel::flushLogs) }, - onShareLogsViaWire = { - contentState.shareLogsViaWire(viewModel::flushLogs) { uri -> - navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination( - ImportMediaNavArgs( - source = ImportSource.INTERNAL_SHARE, - internalAssetUriList = arrayListOf(uri) - ) - ) - ) - ) - } - }, + onShareLogsViaWire = { contentState.shareLogsViaWire(viewModel::flushLogs, onShareLogsViaWire) }, isDBLoggerEnabled = false, onDBLoggerEnabledChange = {}, isPrivateBuild = false diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt index a8986bd78cc..15c14744e3b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/LogManagementViewModel.kt @@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore import com.wire.android.util.logging.LogFileWriter +import dev.zacsweers.metro.Inject import com.wire.kalium.common.logger.CoreLogger import com.wire.kalium.logger.KaliumLogLevel import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch -import dev.zacsweers.metro.Inject data class LogManagementState( val isLoggingEnabled: Boolean = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt index 679de4df3f3..8f2023cbf90 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/UserDebugViewModel.kt @@ -33,10 +33,10 @@ import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase import com.wire.kalium.logic.feature.debug.ChangeProfilingUseCase import com.wire.kalium.logic.feature.debug.ObserveDatabaseLoggerStateUseCase +import dev.zacsweers.metro.Inject import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch -import dev.zacsweers.metro.Inject data class UserDebugState( val isLoggingEnabled: Boolean = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationScreen.kt index 3f8aae0709d..82d7c4bece0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.debug.conversation -import com.wire.android.navigation.annotation.app.WireRootDestination import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -38,8 +37,6 @@ import androidx.compose.ui.text.AnnotatedString import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.Navigator -import com.wire.android.ui.debug.debugConversationViewModel import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -59,16 +56,12 @@ import com.wire.android.ui.theme.wireTypography import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId -@WireRootDestination( - navArgs = DebugConversationScreenNavArgs::class, -) @Composable -fun DebugConversationScreen( - navigator: Navigator, +internal fun DebugConversationRouteScreen( + viewModel: DebugConversationViewModel, + onNavigateBack: () -> Unit, modifier: Modifier = Modifier, - viewModel: DebugConversationViewModel = debugConversationViewModel(), ) { - val context = LocalContext.current val scrollState = rememberScrollState() @@ -84,7 +77,7 @@ fun DebugConversationScreen( }, navigationIconType = NavigationIconType.Close(R.string.content_description_conversation_details_close_btn), onNavigationPressed = { - navigator.navigateBack() + onNavigateBack() } ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModel.kt index af4b321bb46..18ade8ad268 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModel.kt @@ -17,11 +17,9 @@ */ package com.wire.android.ui.debug.conversation -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.appLogger import com.wire.android.ui.common.ActionsViewModel -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.conversation.Conversation @@ -29,6 +27,9 @@ import com.wire.kalium.logic.data.conversation.FetchConversationUseCase import com.wire.kalium.logic.data.conversation.ResetMLSConversationUseCase import com.wire.kalium.logic.feature.conversation.MigrateConversationToMLSUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import com.wire.kalium.logic.feature.debug.DebugFeedConfig import com.wire.kalium.logic.feature.debug.DebugFeedConversationUseCase import com.wire.kalium.logic.feature.debug.DebugFeedResult @@ -38,9 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject class DebugConversationViewModel @AssistedInject constructor( private val conversationDetails: ObserveConversationDetailsUseCase, @@ -49,16 +47,14 @@ class DebugConversationViewModel @AssistedInject constructor( private val feedConversation: DebugFeedConversationUseCase, private val getConversationEpochFromCC: GetConversationEpochFromCCUseCase, private val migrateConversationToMLSUseCase: MigrateConversationToMLSUseCase, - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val args: DebugConversationScreenNavArgs, ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): DebugConversationViewModel + fun create(args: DebugConversationScreenNavArgs): DebugConversationViewModel } - val args: DebugConversationScreenNavArgs = savedStateHandle.navArgs() - val conversationId = args.conversationId private val _state = MutableStateFlow(DebugConversationViewState()) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsScreen.kt index dd43e3db841..499663bb2cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.debug.cryptostats -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -39,7 +38,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.debug.conversationCryptoStatsViewModel import com.wire.android.ui.common.SearchBarInput import com.wire.android.ui.common.chip.WireFilterChip @@ -53,10 +51,9 @@ import com.wire.android.ui.common.typography import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography -@WireRootDestination @Composable -fun ConversationCryptoStatsScreen( - navigator: Navigator, +internal fun ConversationCryptoStatsRouteScreen( + onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: ConversationCryptoStatsViewModel = conversationCryptoStatsViewModel(), ) { @@ -76,7 +73,7 @@ fun ConversationCryptoStatsScreen( ) }, navigationIconType = NavigationIconType.Close(R.string.content_description_conversation_details_close_btn), - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onBack, ) }, content = { paddingValues -> diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsViewModel.kt index e5fa22cc732..d5016f0b62d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/cryptostats/ConversationCryptoStatsViewModel.kt @@ -26,11 +26,11 @@ import com.wire.kalium.logic.feature.debug.ConversationCryptoStats import com.wire.kalium.logic.feature.debug.DetailGroupState import com.wire.kalium.logic.feature.debug.GetConversationCryptoStatsResult import com.wire.kalium.logic.feature.debug.GetConversationCryptoStatsUseCase +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import dev.zacsweers.metro.Inject enum class ProtocolFilter(val label: String) { ALL("All"), diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsScreen.kt index 5206cd6d3af..b589c2169a6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.debug.featureflags -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -29,7 +28,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.debug.debugFeatureFlagsViewModel import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold @@ -38,10 +36,9 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.WireTopAppBarTitle import com.wire.android.ui.common.typography -@WireRootDestination @Composable -fun DebugFeatureFlagsScreen( - navigator: Navigator, +internal fun DebugFeatureFlagsRouteScreen( + onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: DebugFeatureFlagsViewModel = debugFeatureFlagsViewModel(), ) { @@ -61,7 +58,7 @@ fun DebugFeatureFlagsScreen( }, navigationIconType = NavigationIconType.Close(R.string.content_description_conversation_details_close_btn), onNavigationPressed = { - navigator.navigateBack() + onBack() } ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsViewModel.kt index 119d11df367..5588ac7804b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/featureflags/DebugFeatureFlagsViewModel.kt @@ -24,12 +24,12 @@ import com.wire.kalium.logic.data.featureConfig.ChannelFeatureConfiguration import com.wire.kalium.logic.data.featureConfig.Status import com.wire.kalium.logic.feature.debug.GetFeatureConfigResult import com.wire.kalium.logic.feature.debug.GetFeatureConfigUseCase +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import dev.zacsweers.metro.Inject class DebugFeatureFlagsViewModel @Inject constructor( private val getFeatureConfig: GetFeatureConfigUseCase, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentLegacyMappers.kt similarity index 59% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt rename to app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentLegacyMappers.kt index 80d4d77a984..748c8a3ec31 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentLegacyMappers.kt @@ -15,10 +15,16 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.cells.ui.videoplayer -data class VideoViewerNavArgs( - val localPath: String? = null, - val contentUrl: String? = null, - val fileName: String? = null, +package com.wire.android.ui.e2eiEnrollment + +import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs +import com.wire.android.ui.authentication.devices.common.requireWireSessionId +import com.wire.navigation.WireNavEntryId + +internal fun SessionBackedAuthenticationNavArgs.toE2EIEnrollmentRoute( + entryId: WireNavEntryId = WireNavEntryId.random(), +) = E2EIEnrollmentRoute( + sessionId = requireWireSessionId(), + entryId = entryId, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentRoute.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentRoute.kt new file mode 100644 index 00000000000..ed0c3cd7b48 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentRoute.kt @@ -0,0 +1,40 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.e2eiEnrollment + +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import com.wire.navigation.authenticationSessionFlowId +import kotlinx.serialization.Serializable + +@Serializable +data class E2EIEnrollmentRoute( + override val sessionId: WireSessionId, + override val flowId: String = sessionId.authenticationSessionFlowId(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/e2_e_i_enrollment_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt index b3e2cca3441..05b90e57791 100644 --- a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.e2eiEnrollment -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -32,16 +31,8 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import com.wire.android.R -import com.wire.android.feature.NavigationSwitchAccountActions -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation -import com.wire.android.ui.authentication.clearSessionViewModel import com.wire.android.ui.authentication.devices.common.ClearSessionState import com.wire.android.ui.authentication.devices.common.ClearSessionViewModel -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.common.TextWithLearnMore import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -53,12 +44,8 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.wire.android.ui.e2EIEnrollmentViewModel -import com.ramcosta.composedestinations.generated.app.destinations.E2EiCertificateDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination import com.wire.android.ui.home.E2EIEnrollmentErrorWithDismissDialog import com.wire.android.ui.home.E2EISuccessDialog -import com.wire.android.ui.settings.devices.e2ei.E2EICertificateDetails import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -67,18 +54,13 @@ import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult -@WireRootDestination( - style = PopUpNavigationAnimation::class, - navArgs = SessionBackedAuthenticationNavArgs::class, -) @Composable -@Suppress("UnusedParameter") -fun E2EIEnrollmentScreen( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, - sessionBackedAuthenticationNavArgs: SessionBackedAuthenticationNavArgs, - viewModel: E2EIEnrollmentViewModel = e2EIEnrollmentViewModel(), - clearSessionViewModel: ClearSessionViewModel = clearSessionViewModel(), +internal fun E2EIEnrollmentRouteScreen( + viewModel: E2EIEnrollmentViewModel, + clearSessionViewModel: ClearSessionViewModel, + switchAccountActions: com.wire.android.feature.SwitchAccountActions, + onInitialSyncRequired: () -> Unit, + onOpenCertificateDetails: (String) -> Unit, ) { val state = viewModel.state @@ -87,26 +69,16 @@ fun E2EIEnrollmentScreen( clearSessionState = clearSessionViewModel.state, dismissSuccess = { viewModel.finalizeMLSClient { - navigator.navigate(NavigationCommand(InitialSyncScreenDestination, BackStackMode.CLEAR_WHOLE)) + onInitialSyncRequired() } }, dismissErrorDialog = viewModel::dismissErrorDialog, enrollE2EICertificate = viewModel::enrollE2EICertificate, handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, - openCertificateDetails = { - navigator.navigate( - NavigationCommand( - E2EiCertificateDetailsScreenDestination( - E2EICertificateDetails.DuringLoginCertificateDetails(state.certificate) - ) - ) - ) - }, + openCertificateDetails = { onOpenCertificateDetails(state.certificate) }, onBackButtonClicked = clearSessionViewModel::onBackButtonClicked, onCancelEnrollmentClicked = { - clearSessionViewModel.onCancelLoginClicked( - NavigationSwitchAccountActions(navigator::navigate, loginTypeSelector::canUseNewLogin) - ) + clearSessionViewModel.onCancelLoginClicked(switchAccountActions) }, onProceedEnrollmentClicked = clearSessionViewModel::onProceedLoginClicked, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindings.kt new file mode 100644 index 00000000000..14ab08aab32 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindings.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.home + +import androidx.lifecycle.ViewModel +import com.wire.android.BuildConfig +import com.wire.android.ui.home.conversationslist.ConversationListViewModelImpl +import com.wire.android.ui.home.conversationslist.model.ConversationsSource +import com.wire.android.ui.home.drawer.HomeDrawerViewModel +import com.wire.android.ui.home.newconversation.NewConversationViewModel +import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +@BindingContainer +object HomeMetroViewModelBindings { + + @Provides + @IntoMap + @ViewModelKey(AppSyncViewModel::class) + fun appSyncViewModel(viewModel: AppSyncViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ViewModelKey(FeatureFlagNotificationViewModel::class) + fun featureFlagNotificationViewModel(viewModel: FeatureFlagNotificationViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ViewModelKey(HomeViewModel::class) + fun homeViewModel(viewModel: HomeViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ViewModelKey(HomeDrawerViewModel::class) + fun homeDrawerViewModel(viewModel: HomeDrawerViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ViewModelKey(NewConversationViewModel::class) + fun newConversationViewModel(viewModel: NewConversationViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(HomeManualViewModelFactory::class) + internal fun homeManualViewModelFactory( + factory: ConversationListViewModelImpl.Factory, + ): ManualViewModelAssistedFactory = object : HomeManualViewModelFactory { + override fun conversationListViewModel(conversationsSource: ConversationsSource): ConversationListViewModelImpl = + factory.create( + conversationsSource = conversationsSource, + usePagination = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavGraph.kt deleted file mode 100644 index ce8cb72aca4..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavGraph.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.home - -import com.ramcosta.composedestinations.animations.defaults.DefaultFadingTransitions -import com.ramcosta.composedestinations.annotation.NavHostGraph - -@NavHostGraph(defaultTransitions = DefaultFadingTransitions::class) -annotation class HomeNavGraph( - val start: Boolean = false -) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt new file mode 100644 index 00000000000..4da1fc6987f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt @@ -0,0 +1,488 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.wire.android.R +import com.wire.android.ui.common.R as commonR +import com.wire.android.navigation.HomeDestination +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.analytics.AnalyticsUsageViewModel +import com.wire.android.ui.common.HandleActions +import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog +import com.wire.android.ui.common.bottomsheet.WireModalSheetState +import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.search.SearchBarState +import com.wire.android.ui.common.search.rememberSearchbarState +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.common.topappbar.ConversationFilterState +import com.wire.android.ui.common.topappbar.rememberConversationFilterState +import com.wire.android.ui.home.conversations.ConversationCompletionAction +import com.wire.android.ui.home.conversations.ConversationCompletionNavigation3ResultType +import com.wire.android.ui.home.conversations.ConversationCompletionResult +import com.wire.android.ui.home.conversations.ConversationAuxId +import com.wire.android.ui.home.conversations.ConversationFoldersNavigation3ResultType +import com.wire.android.ui.home.conversations.ConversationFoldersRoute +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.home.conversations.ConversationRouteId +import com.wire.android.ui.home.drawer.HomeDrawerViewModel +import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState +import com.wire.android.ui.home.conversationslist.filter.toTopBarTitle +import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.conversationslist.all.AllConversationsContent +import com.wire.android.ui.analyticsUsageViewModel +import com.wire.android.util.ui.LazyListStateProvider +import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.rememberLazyListStateProvider +import com.wire.android.util.permission.rememberShowNotificationsPermissionFlow +import com.wire.kalium.logic.data.conversation.ConversationFilter +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.android.ui.userprofile.ConnectionRequestIgnoredNavigation3ResultType +import com.wire.android.ui.userprofile.other.ConnectionRequestIgnoredResult +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Stable semantic identity of the content displayed inside the Home chrome. + * + * These are deliberately not Navigation 3 back-stack entries: switching a drawer item preserves + * the Home entry, drawer state, search state and per-item list state exactly like the old nested + * Home graph did. + */ +internal enum class HomeTopLevelDestination { + CONVERSATIONS, + SETTINGS, + VAULT, + ARCHIVE, + WHATS_NEW, + CELLS, + MEETINGS, +} + +internal enum class HomeExternalDestination { + SUPPORT, + TEAM_MANAGEMENT, +} + +internal sealed interface HomeNavigation3Target { + data class TopLevel(val destination: HomeTopLevelDestination) : HomeNavigation3Target + data class External(val destination: HomeExternalDestination) : HomeNavigation3Target +} + +internal fun HomeDestination.toNavigation3Target(): HomeNavigation3Target = when (this) { + HomeDestination.Conversations -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.CONVERSATIONS) + HomeDestination.Settings -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.SETTINGS) + HomeDestination.Vault -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.VAULT) + HomeDestination.Archive -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.ARCHIVE) + HomeDestination.WhatsNew -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.WHATS_NEW) + HomeDestination.Cells -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.CELLS) + HomeDestination.Meetings -> HomeNavigation3Target.TopLevel(HomeTopLevelDestination.MEETINGS) + HomeDestination.Support -> HomeNavigation3Target.External(HomeExternalDestination.SUPPORT) + HomeDestination.TeamManagement -> HomeNavigation3Target.External(HomeExternalDestination.TEAM_MANAGEMENT) +} + +internal fun HomeTopLevelDestination.toHomeDestination(): HomeDestination = when (this) { + HomeTopLevelDestination.CONVERSATIONS -> HomeDestination.Conversations + HomeTopLevelDestination.SETTINGS -> HomeDestination.Settings + HomeTopLevelDestination.VAULT -> HomeDestination.Vault + HomeTopLevelDestination.ARCHIVE -> HomeDestination.Archive + HomeTopLevelDestination.WHATS_NEW -> HomeDestination.WhatsNew + HomeTopLevelDestination.CELLS -> HomeDestination.Cells + HomeTopLevelDestination.MEETINGS -> HomeDestination.Meetings +} + +/** + * Drawer destinations share one [HomeRoute] entry, so the Navigation 3 back stack cannot restore + * Conversations for us. Preserve the legacy nested-Home behavior explicitly: back from any + * secondary Home destination first returns to the conversation list. + */ +internal fun HomeTopLevelDestination.backDestination(): HomeTopLevelDestination? = + HomeTopLevelDestination.CONVERSATIONS.takeUnless { this == it } + +/** + * The typed boundary between the Home shell and children that are migrated independently. + * + * No generated direction or NavController crosses this API. A child can therefore move to a typed + * route without changing the drawer/top-bar owner. + */ +internal interface HomeNavigation3Actions { + val conversations: ConversationsNavigationActions + val topLevel: HomeTopLevelNavigation3Actions + + fun onRequirement(requirement: HomeRequirement) + fun openNewConversation() + fun openSelfProfile() + fun openExternal(destination: HomeExternalDestination) +} + +internal object HomeNavigation3Contribution { + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: HomeNavigation3Actions, + ): List = listOf(homeNavigation3Entries(runtime, actions)) +} + +internal fun homeNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: HomeNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry { route -> + HomeNavigation3Entry(route, runtime, actions) + } +} + +@Composable +@Suppress("CyclomaticComplexMethod") +private fun HomeNavigation3Entry( + route: HomeRoute, + runtime: WireNavigation3Runtime, + actions: HomeNavigation3Actions, + homeViewModel: HomeViewModel = homeViewModel(), + appSyncViewModel: AppSyncViewModel = appSyncViewModel(), + homeDrawerViewModel: HomeDrawerViewModel = homeDrawerViewModel(), + analyticsUsageViewModel: AnalyticsUsageViewModel = analyticsUsageViewModel(), +) { + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + val selectedDestination = rememberSaveable(route.entryId.value) { + mutableStateOf(HomeTopLevelDestination.CONVERSATIONS) + } + var conversationRequestIdValue by rememberSaveable(route.entryId.value) { + mutableStateOf(null) + } + var userProfileRequestIdValue by rememberSaveable(route.entryId.value) { + mutableStateOf(null) + } + var folderRequestIdValue by rememberSaveable(route.entryId.value) { + mutableStateOf(null) + } + val shellState = rememberHomeNavigation3ShellState(selectedDestination) + val lifecycleOwner = LocalLifecycleOwner.current + val notificationsPermissionDeniedDialogState = + rememberVisibilityState() + val showNotificationsFlow = rememberShowNotificationsPermissionFlow( + onPermissionGranted = { /* no-op */ }, + onPermissionDenied = { + notificationsPermissionDeniedDialogState.show( + PermissionPermanentlyDeniedDialogState.Visible( + title = commonR.string.app_permission_dialog_title, + description = R.string.notifications_permission_dialog_description, + ) + ) + }, + onPermissionPermanentlyDenied = { /* no-op */ }, + ) + + LaunchedEffect(route.entryId.value, homeViewModel) { + homeViewModel.checkRequirements() + } + HandleActions(homeViewModel.actions, actions::onRequirement) + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + appSyncViewModel.startSyncingAppConfig() + shellState.clearSearchOnResumeIfRequested() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + LaunchedEffect(route.entryId.value) { + showNotificationsFlow.launch() + } + val currentEntryId = runtime.navigator.currentRoute?.entryId + LaunchedEffect( + currentEntryId, + conversationRequestIdValue, + userProfileRequestIdValue, + folderRequestIdValue, + ) { + if (currentEntryId != route.entryId) return@LaunchedEffect + + conversationRequestIdValue?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, ConversationCompletionNavigation3ResultType)) { + is WireNavResult.Value -> { + snackbarHostState.showSnackbar( + result.value.toHomeSnackBarMessage().uiText.asString(context.resources) + ) + conversationRequestIdValue = null + } + + WireNavResult.Canceled -> conversationRequestIdValue = null + null -> Unit + } + } + userProfileRequestIdValue?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, ConnectionRequestIgnoredNavigation3ResultType)) { + is WireNavResult.Value -> { + snackbarHostState.showSnackbar( + result.value.toHomeSnackBarMessage().uiText.asString(context.resources) + ) + userProfileRequestIdValue = null + } + + WireNavResult.Canceled -> userProfileRequestIdValue = null + null -> Unit + } + } + folderRequestIdValue?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, ConversationFoldersNavigation3ResultType)) { + is WireNavResult.Value -> { + snackbarHostState.showSnackbar(result.value.message) + folderRequestIdValue = null + } + + WireNavResult.Canceled -> folderRequestIdValue = null + null -> Unit + } + } + } + + val conversationsNavigationActions = remember(route.sessionId, runtime, actions.conversations) { + actions.conversations.copy( + openConversation = { conversationId -> + val requestId = runtime.navigateForResult( + destination = ConversationRoute( + sessionId = route.sessionId, + conversationId = ConversationRouteId(conversationId.value, conversationId.domain), + ), + resultType = ConversationCompletionNavigation3ResultType, + ) + if (requestId == null) { + actions.conversations.openConversation(conversationId) + } else { + conversationRequestIdValue = requestId.value + } + }, + openUserProfile = { userId -> + val requestId = runtime.navigateForResult( + destination = OtherUserProfileRoute( + sessionId = route.sessionId, + targetUserId = UserProfileQualifiedId(userId.value, userId.domain), + ), + resultType = ConnectionRequestIgnoredNavigation3ResultType, + ) + if (requestId == null) { + actions.conversations.openUserProfile(userId) + } else { + userProfileRequestIdValue = requestId.value + } + }, + openConversationFolders = { args -> + folderRequestIdValue = runtime.navigateForResult( + destination = ConversationFoldersRoute( + sessionId = route.sessionId, + conversationId = ConversationAuxId( + args.conversationId.value, + args.conversationId.domain, + ), + conversationName = args.conversationName, + currentFolderId = args.currentFolderId, + ), + resultType = ConversationFoldersNavigation3ResultType, + )?.value + }, + ) + } + + if (analyticsUsageViewModel.state.shouldDisplayDialog) { + AnalyticsUsageDialog( + agreeOption = analyticsUsageViewModel::agreeAnalyticsUsage, + declineOption = analyticsUsageViewModel::declineAnalyticsUsage, + ) + } + + HomeContent( + homeState = homeViewModel.homeState, + homeDrawerState = homeDrawerViewModel.drawerState, + homeStateHolder = shellState, + onNewConversationClick = actions::openNewConversation, + onSelfUserClick = actions::openSelfProfile, + onNavigateToHomeItem = { item -> + when (val target = item.toNavigation3Target()) { + is HomeNavigation3Target.TopLevel -> shellState.select(target.destination) + is HomeNavigation3Target.External -> actions.openExternal(target.destination) + } + }, + content = { + when (shellState.selectedDestination) { + HomeTopLevelDestination.CONVERSATIONS -> AllConversationsContent( + homeShellState = shellState, + navigationActions = conversationsNavigationActions, + ) + + else -> HomeNavigation3TopLevelContent( + destination = shellState.selectedDestination, + shellState = shellState, + sessionId = route.sessionId, + runtime = runtime, + actions = actions.topLevel, + conversationsNavigationActions = conversationsNavigationActions, + ) + } + }, + ) + + val topLevelBackDestination = shellState.selectedDestination.backDestination() + BackHandler(topLevelBackDestination != null) { + topLevelBackDestination?.let(shellState::select) + } + BackHandler(shellState.drawerState.isOpen) { + shellState.closeDrawer() + } + BackHandler(shellState.searchBarState.isSearchActive) { + shellState.searchBarState.closeSearch() + } + PermissionPermanentlyDeniedDialog( + dialogState = notificationsPermissionDeniedDialogState, + hideDialog = notificationsPermissionDeniedDialogState::dismiss, + ) +} + +internal fun ConversationCompletionResult.toHomeSnackBarMessage(): HomeSnackBarMessage = + when (action) { + ConversationCompletionAction.LEAVE_GROUP -> HomeSnackBarMessage.LeftConversationSuccess + ConversationCompletionAction.DELETE_GROUP -> + HomeSnackBarMessage.DeletedConversationGroupSuccess(conversationName) + } + +internal fun ConnectionRequestIgnoredResult.toHomeSnackBarMessage(): HomeSnackBarMessage = + HomeSnackBarMessage.SuccessConnectionIgnoreRequest(userName) + +@Suppress("ComposeMutableParameters") +@Composable +private fun rememberHomeNavigation3ShellState( + selectedDestinationState: MutableState, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), +): HomeNavigation3ShellState { + val searchBarState = rememberSearchbarState() + val conversationFilterState = rememberConversationFilterState() + val conversationsFilterBottomSheetState = rememberWireModalSheetState() + val newMeetingBottomSheetState = rememberWireModalSheetState() + val lazyListStateProvider = rememberLazyListStateProvider() + val currentNavigationItemState = remember(selectedDestinationState) { + derivedStateOf { selectedDestinationState.value.toHomeDestination() } + } + + return remember { + HomeNavigation3ShellState( + coroutineScope = coroutineScope, + drawerState = drawerState, + searchBarState = searchBarState, + conversationsFilterBottomSheetState = conversationsFilterBottomSheetState, + newMeetingBottomSheetState = newMeetingBottomSheetState, + currentNavigationItemState = currentNavigationItemState, + selectedDestinationState = selectedDestinationState, + conversationFilterState = conversationFilterState, + lazyListStateProvider = lazyListStateProvider, + ) + } +} + +@Suppress("LongParameterList") +private class HomeNavigation3ShellState( + override val coroutineScope: CoroutineScope, + override val drawerState: DrawerState, + override val searchBarState: SearchBarState, + override val conversationsFilterBottomSheetState: WireModalSheetState, + override val newMeetingBottomSheetState: WireModalSheetState, + private val currentNavigationItemState: State, + private val selectedDestinationState: MutableState, + private val conversationFilterState: ConversationFilterState, + private val lazyListStateProvider: LazyListStateProvider, +) : HomeShellState { + override val emptySearchResultFocusRequester = androidx.compose.ui.focus.FocusRequester() + override val firstConversationFocusRequester = androidx.compose.ui.focus.FocusRequester() + + val selectedDestination: HomeTopLevelDestination + get() = selectedDestinationState.value + + override val currentNavigationItem: HomeDestination + get() = currentNavigationItemState.value + + override val currentConversationFilter: ConversationFilter + get() = conversationFilterState.filter + + override val currentTitle: UIText + get() = when (currentNavigationItem) { + HomeDestination.Conversations -> currentConversationFilter.toTopBarTitle() + else -> currentNavigationItem.title + } + + fun select(destination: HomeTopLevelDestination) { + selectedDestinationState.value = destination + } + + override fun lazyListStateFor( + destination: HomeDestination, + conversationFilter: ConversationFilter, + ): LazyListState = lazyListStateProvider.get( + destination.itemName + if (destination == HomeDestination.Conversations) ":$conversationFilter" else "" + ) + + override fun closeDrawer() { + coroutineScope.launch { drawerState.close() } + } + + override fun openDrawer() { + coroutineScope.launch { drawerState.open() } + } + + override fun changeConversationFilter(filter: ConversationFilter) { + lazyListStateFor(HomeDestination.Conversations, currentConversationFilter).requestScrollToItem(0, 0) + conversationFilterState.changeFilter(filter) + } + + override fun requestClearSearchOnNextResume() { + searchBarState.requestClearSearchOnNextResume() + } + + override fun clearSearchOnResumeIfRequested() { + searchBarState.clearSearchOnResumeIfRequested() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt new file mode 100644 index 00000000000..581a49c8121 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt @@ -0,0 +1,121 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.ui.home + +import androidx.compose.runtime.Composable +import com.wire.android.feature.cells.ui.AllFilesNavigationActions +import com.wire.android.feature.cells.ui.CellFilesNavArgs +import com.wire.android.feature.cells.ui.CellViewModel +import com.wire.android.feature.cells.ui.cellViewModel +import com.wire.android.feature.meetings.ui.create.NewMeetingType +import com.wire.android.navigation.HomeDestination +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.ui.home.archive.ArchiveScreen +import com.wire.android.ui.home.cell.GlobalCellsScreen +import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.meetings.MeetingsScreen +import com.wire.android.ui.home.settings.SettingsNavigation3Actions +import com.wire.android.ui.home.settings.SettingsNavigation3Root +import com.wire.android.ui.home.vault.VaultScreen +import com.wire.android.ui.home.whatsnew.WhatsNewNavigation3Target +import com.wire.android.ui.home.whatsnew.WhatsNewScreen +import com.wire.navigation.WireSessionId + +/** + * Cross-batch actions emitted by Home top-level children. + * + * Generated directions and a Nav2 controller are intentionally excluded. The production host can + * map these actions to typed feature routes as each owning batch lands. + */ +internal interface HomeTopLevelNavigation3Actions { + val settings: SettingsNavigation3Actions + + /** + * Temporary semantic boundary for Cells details whose typed contracts are owned by the Cells + * migration: Search, Public Link, Add/Remove Tags, Image Viewer and Video Player. + */ + val cells: AllFilesNavigationActions + + /** Welcome/release-note detail contracts are migrated by the What's New detail batch. */ + fun openWhatsNew(target: WhatsNewNavigation3Target) + + /** New Meeting is owned by the Meetings feature graph and is migrated with that graph. */ + fun openNewMeeting(type: NewMeetingType) +} + +/** + * Real top-level renderer for the Navigation 3 Home shell. + * + * Drawer selection remains state within Home rather than a second back-stack entry. Detail screens + * opened from these roots are regular typed entries owned by their respective feature contribution. + */ +@Composable +internal fun HomeNavigation3TopLevelContent( + destination: HomeTopLevelDestination, + shellState: HomeShellState, + sessionId: WireSessionId, + runtime: WireNavigation3Runtime, + actions: HomeTopLevelNavigation3Actions, + conversationsNavigationActions: ConversationsNavigationActions, +) { + when (destination) { + HomeTopLevelDestination.CONVERSATIONS -> + error("Conversations is rendered directly by HomeNavigation3Entry") + + HomeTopLevelDestination.SETTINGS -> SettingsNavigation3Root( + sessionId = sessionId, + runtime = runtime, + actions = actions.settings, + lazyListState = shellState.lazyListStateFor(HomeDestination.Settings), + ) + + HomeTopLevelDestination.VAULT -> VaultScreen() + + HomeTopLevelDestination.ARCHIVE -> ArchiveScreen( + homeShellState = shellState, + navigationActions = conversationsNavigationActions, + ) + + HomeTopLevelDestination.WHATS_NEW -> WhatsNewScreen( + homeShellState = shellState, + onOpenTarget = actions::openWhatsNew, + ) + + HomeTopLevelDestination.CELLS -> Navigation3GlobalCells(actions.cells) + + HomeTopLevelDestination.MEETINGS -> MeetingsScreen( + homeShellState = shellState, + onOpenNewMeeting = actions::openNewMeeting, + ) + } +} + +@Suppress("ComposeViewModelForwarding") +@Composable +private fun Navigation3GlobalCells( + navigationActions: AllFilesNavigationActions, + viewModel: CellViewModel = cellViewModel(CellFilesNavArgs()), +) { + GlobalCellsScreen( + navigationActions = navigationActions, + viewModel = viewModel, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt index 3e1c13f1848..b307504e1c4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScaffold.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,18 +68,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.generated.app.destinations.GlobalCellsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.navgraphs.HomeGraph -import com.ramcosta.composedestinations.navigation.dependency -import com.ramcosta.composedestinations.navigation.destination -import com.wire.android.feature.cells.ui.cellViewModel import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeDestination.FabOptions -import com.wire.android.navigation.rememberWireNavHostEngine import com.wire.android.ui.common.CollapsingTopBarScaffold import com.wire.android.ui.common.button.FloatingActionButton import com.wire.android.ui.common.dimensions @@ -91,7 +80,7 @@ import com.wire.android.ui.home.drawer.HomeDrawerState @Composable internal fun HomeDrawerSheet( - currentRoute: String, + currentDestination: HomeDestination, homeDrawerState: HomeDrawerState, focusTrapState: HomeDrawerSheetFocusTrapState, focusTrapActions: HomeDrawerSheetFocusTrapActions, @@ -114,7 +103,7 @@ internal fun HomeDrawerSheet( ) ) { HomeDrawer( - currentRoute = currentRoute, + currentDestination = currentDestination, homeDrawerState = homeDrawerState, navigateToHomeItem = onNavigateToHomeItem, onCloseDrawer = onCloseDrawer, @@ -127,7 +116,7 @@ internal fun HomeDrawerSheet( } @Stable -internal class HomeScaffoldState(val holder: HomeStateHolder) +internal class HomeScaffoldState(val holder: HomeShellState) internal data class HomeScaffoldFocusRequesters( val search: FocusRequester, @@ -149,6 +138,7 @@ internal fun HomeScaffold( drawerState: DrawerState, focusRequesters: HomeScaffoldFocusRequesters, actions: HomeScaffoldActions, + content: @Composable () -> Unit, ) { with(state.holder) { CollapsingTopBarScaffold( @@ -176,7 +166,7 @@ internal fun HomeScaffold( collapsingEnabled = !searchBarState.isSearchActive, contentLazyListState = lazyListStateFor(currentNavigationItem, currentConversationFilter), content = { - HomeNavHost(state = state) + content() }, floatingActionButton = { HomeScaffoldFloatingActionButton( @@ -261,39 +251,6 @@ private fun HomeSearchTopBar( } } -@Composable -private fun HomeNavHost(state: HomeScaffoldState) { - /** - * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. - * We need to communicate to the sub-compositions when they are to be disposed by the parent and ignore - * compositions in the round they are to be disposed. More here: - * https://github.com/google/accompanist/issues/1487 - * https://issuetracker.google.com/issues/268422136 - * https://issuetracker.google.com/issues/254645321 - */ - val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() - if (lifecycleState != Lifecycle.State.DESTROYED) { - val navHostEngine = rememberWireNavHostEngine() - DestinationsNavHost( - navGraph = HomeGraph, - start = HomeGraph.defaultStartDirection, - engine = navHostEngine, - navController = state.holder.navController, - dependenciesContainerBuilder = { - dependency(state.holder) - - // Scope CellViewModel to HomeScreen so SearchScreen can reuse it via previousBackStackEntry. - destination(GlobalCellsScreenDestination) { - val parentEntry = remember(navBackStackEntry) { - state.holder.navigator.navController.getBackStackEntry(HomeScreenDestination.route) - } - dependency(cellViewModel(parentEntry)) - } - } - ) - } -} - @OptIn(ExperimentalAnimationApi::class) @Composable private fun HomeScaffoldFloatingActionButton( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index b3cbe5c95a6..bdfaac108a2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -18,218 +18,33 @@ package com.wire.android.ui.home -import androidx.activity.compose.BackHandler import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient -import com.wire.android.R -import com.wire.android.ui.common.R as commonR -import com.wire.android.appLogger import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.handleNavigation -import com.wire.android.ui.analytics.AnalyticsUsageViewModel -import com.wire.android.ui.common.HandleActions -import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog -import com.wire.android.ui.common.snackbar.LocalSnackbarHostState -import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState -import com.wire.android.ui.home.conversations.details.GroupConversationActionType -import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs -import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavBackArgs import com.wire.android.ui.home.drawer.HomeDrawerState -import com.wire.android.ui.home.drawer.HomeDrawerViewModel -import com.wire.android.ui.analyticsUsageViewModel -import com.wire.android.util.permission.rememberShowNotificationsPermissionFlow -import kotlinx.coroutines.launch - -@WireRootDestination -@Composable -fun HomeScreen( - navigator: Navigator, - groupDetailsScreenResultRecipient: - ResultRecipient, - otherUserProfileScreenResultRecipient: ResultRecipient, - conversationFoldersScreenResultRecipient: - ResultRecipient, - homeViewModel: HomeViewModel = homeViewModel(), - appSyncViewModel: AppSyncViewModel = appSyncViewModel(), - homeDrawerViewModel: HomeDrawerViewModel = homeDrawerViewModel(), - analyticsUsageViewModel: AnalyticsUsageViewModel = analyticsUsageViewModel(), -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - homeViewModel.checkRequirements() - - HandleActions(homeViewModel.actions) { action -> - action.navigate(navigator::navigate) - } - - val homeScreenState = rememberHomeScreenState(navigator) - val notificationsPermissionDeniedDialogState = rememberVisibilityState() - val showNotificationsPermissionDeniedDialog = { - notificationsPermissionDeniedDialogState.show( - PermissionPermanentlyDeniedDialogState.Visible( - title = commonR.string.app_permission_dialog_title, - description = R.string.notifications_permission_dialog_description, - ) - ) - } - val showNotificationsFlow = - rememberShowNotificationsPermissionFlow( - onPermissionGranted = { /* do nothing */ }, - onPermissionDenied = showNotificationsPermissionDeniedDialog, - onPermissionPermanentlyDenied = { /* do nothing */ }, - ) - - val lifecycleOwner = LocalLifecycleOwner.current - val snackbarHostState = LocalSnackbarHostState.current - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - appSyncViewModel.startSyncingAppConfig() - homeScreenState.clearSearchOnResumeIfRequested() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LaunchedEffect(homeViewModel.savedStateHandle) { - showNotificationsFlow.launch() - } - - val homeState = homeViewModel.homeState - - if (analyticsUsageViewModel.state.shouldDisplayDialog) { - AnalyticsUsageDialog( - agreeOption = analyticsUsageViewModel::agreeAnalyticsUsage, - declineOption = analyticsUsageViewModel::declineAnalyticsUsage - ) - } - - HomeContent( - homeState = homeState, - homeDrawerState = homeDrawerViewModel.drawerState, - homeStateHolder = homeScreenState, - onNewConversationClick = { navigator.navigate(NavigationCommand(NewConversationSearchPeopleScreenDestination)) }, - onSelfUserClick = { - // Temporarily stopping sending ui.clicked-profile event - // homeViewModel.sendOpenProfileEvent() - navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) - }, - ) - - BackHandler(homeScreenState.drawerState.isOpen) { - homeScreenState.coroutineScope.launch { - homeScreenState.drawerState.close() - } - } - BackHandler(homeScreenState.searchBarState.isSearchActive) { - homeScreenState.searchBarState.closeSearch() - } - - groupDetailsScreenResultRecipient.onNavResult { result -> - when (result) { - is NavResult.Canceled -> { - appLogger.i("Error with receiving navigation back args from groupDetails in ConversationScreen") - } - - is NavResult.Value -> { - when (result.value.groupConversationActionType) { - GroupConversationActionType.LEAVE_GROUP -> { - coroutineScope.launch { - snackbarHostState.showSnackbar((HomeSnackBarMessage.LeftConversationSuccess.uiText.asString(context.resources))) - } - } - - GroupConversationActionType.DELETE_GROUP -> { - coroutineScope.launch { - snackbarHostState.showSnackbar( - HomeSnackBarMessage.DeletedConversationGroupSuccess(result.value.conversationName).uiText.asString( - context.resources - ) - ) - } - } - } - } - } - } - - otherUserProfileScreenResultRecipient.onNavResult { result -> - when (result) { - is NavResult.Canceled -> { - appLogger.i("Error with receiving navigation back args from OtherUserProfile in ConversationScreen") - } - - is NavResult.Value -> { - coroutineScope.launch { - snackbarHostState.showSnackbar( - HomeSnackBarMessage.SuccessConnectionIgnoreRequest(result.value).uiText.asString(context.resources) - ) - } - } - } - } - - conversationFoldersScreenResultRecipient.onNavResult { result -> - when (result) { - NavResult.Canceled -> {} - is NavResult.Value -> { - coroutineScope.launch { - snackbarHostState.showSnackbar(result.value.message) - } - } - } - } - - PermissionPermanentlyDeniedDialog( - dialogState = notificationsPermissionDeniedDialogState, - hideDialog = notificationsPermissionDeniedDialogState::dismiss - ) -} @OptIn(ExperimentalAnimationApi::class) @Composable fun HomeContent( homeState: HomeState, homeDrawerState: HomeDrawerState, - homeStateHolder: HomeStateHolder, + homeStateHolder: HomeShellState, onNewConversationClick: () -> Unit, onSelfUserClick: () -> Unit, + onNavigateToHomeItem: (HomeDestination) -> Unit, + content: @Composable () -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } val fabFocusRequester = remember { FocusRequester() } @@ -261,23 +76,6 @@ fun HomeContent( } } - fun openWireHomeDestination(item: HomeDestination) { - item.direction.handleNavigation( - context = context, - handleOtherDirection = { direction -> - navController.navigate(direction.route) { - navController.graph.startDestinationRoute?.let { route -> - popUpTo(route) { - saveState = true - } - } - launchSingleTop = true - restoreState = true - } - }, - ) - } - val drawerSheetFocusTrapState = HomeDrawerSheetFocusTrapState( enabled = drawerState.isOpen, focusRequester = drawerFocusRequester, @@ -299,13 +97,13 @@ fun HomeContent( drawerState = drawerState, drawerContent = { HomeDrawerSheet( - currentRoute = currentNavigationItem.direction.route, + currentDestination = currentNavigationItem, homeDrawerState = homeDrawerState, focusTrapState = drawerSheetFocusTrapState, focusTrapActions = drawerSheetFocusTrapActions, firstItemFocusRequester = firstDrawerItemFocusRequester, lastItemFocusRequester = lastDrawerItemFocusRequester, - onNavigateToHomeItem = ::openWireHomeDestination, + onNavigateToHomeItem = onNavigateToHomeItem, onCloseDrawer = ::closeHomeDrawer ) }, @@ -324,7 +122,8 @@ fun HomeContent( onNewConversationClick = onNewConversationClick, onSelfUserClick = onSelfUserClick, onHamburgerMenuClick = ::openDrawer - ) + ), + content = content, ) } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeShellState.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeShellState.kt new file mode 100644 index 00000000000..62bc70a1d73 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeShellState.kt @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.DrawerState +import androidx.compose.ui.focus.FocusRequester +import com.wire.android.navigation.HomeDestination +import com.wire.android.ui.common.bottomsheet.WireModalSheetState +import com.wire.android.ui.common.search.SearchBarState +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.conversation.ConversationFilter +import kotlinx.coroutines.CoroutineScope + +/** + * Navigation-neutral state owned by the Navigation 3 Home entry. + * + * Top-level Home destinations are content selections rather than nested back-stack entries, so + * drawer, search, filters and per-content scroll state survive selection changes in one owner. + */ +interface HomeShellState { + val coroutineScope: CoroutineScope + val drawerState: DrawerState + val searchBarState: SearchBarState + val conversationsFilterBottomSheetState: WireModalSheetState + val newMeetingBottomSheetState: WireModalSheetState + val emptySearchResultFocusRequester: FocusRequester + val firstConversationFocusRequester: FocusRequester + val currentNavigationItem: HomeDestination + val currentConversationFilter: ConversationFilter + val currentTitle: UIText + + fun lazyListStateFor( + destination: HomeDestination, + conversationFilter: ConversationFilter = ConversationFilter.All, + ): LazyListState + + fun closeDrawer() + fun openDrawer() + fun changeConversationFilter(filter: ConversationFilter) + fun requestClearSearchOnNextResume() + fun clearSearchOnResumeIfRequested() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeState.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeState.kt index b387512d701..af76c3c2f64 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeState.kt @@ -19,12 +19,6 @@ package com.wire.android.ui.home import com.wire.android.model.UserAvatarData -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountUsernameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RegisterDeviceScreenDestination import com.wire.kalium.logic.data.user.UserId data class HomeState( @@ -37,15 +31,4 @@ sealed class HomeRequirement { data class RegisterDevice(val userId: UserId) : HomeRequirement() data object CreateAccountUsername : HomeRequirement() data object InitialSync : HomeRequirement() - - fun navigate(navigate: (NavigationCommand) -> Unit) = when (this) { - is RegisterDevice -> navigate( - NavigationCommand( - RegisterDeviceScreenDestination(SessionBackedAuthenticationNavArgs.from(userId)), - BackStackMode.CLEAR_WHOLE, - ) - ) - is CreateAccountUsername -> navigate(NavigationCommand(CreateAccountUsernameScreenDestination, BackStackMode.CLEAR_WHOLE)) - is InitialSync -> navigate(NavigationCommand(InitialSyncScreenDestination, BackStackMode.CLEAR_WHOLE)) - } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt deleted file mode 100644 index a2111eaa30b..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.ui.home - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.focus.FocusRequester -import androidx.navigation.NavHostController -import androidx.navigation.compose.currentBackStackEntryAsState -import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.HomeDestination.Conversations -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.rememberTrackingAnimatedNavController -import com.wire.android.ui.common.bottomsheet.WireModalSheetState -import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState -import com.wire.android.ui.common.search.SearchBarState -import com.wire.android.ui.common.search.rememberSearchbarState -import com.wire.android.ui.common.topappbar.ConversationFilterState -import com.wire.android.ui.common.topappbar.rememberConversationFilterState -import com.wire.android.ui.home.conversationslist.filter.toTopBarTitle -import com.wire.android.util.ui.LazyListStateProvider -import com.wire.android.util.ui.rememberLazyListStateProvider -import com.wire.kalium.logic.data.conversation.ConversationFilter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@Suppress("LongParameterList") -class HomeStateHolder( - val coroutineScope: CoroutineScope, - val navController: NavHostController, - val drawerState: DrawerState, - val searchBarState: SearchBarState, - val conversationsFilterBottomSheetState: WireModalSheetState, - val newMeetingBottomSheetState: WireModalSheetState, - val navigator: Navigator, - private val currentNavigationItemState: State, - private val conversationFilterState: ConversationFilterState, - private val lazyListStateProvider: LazyListStateProvider, -) { - val emptySearchResultFocusRequester = FocusRequester() - val firstConversationFocusRequester = FocusRequester() - - val currentNavigationItem - get() = currentNavigationItemState.value - - val currentConversationFilter - get() = conversationFilterState.filter - - val currentTitle - get() = when (currentNavigationItemState.value) { - Conversations -> conversationFilterState.filter.toTopBarTitle() - else -> currentNavigationItemState.value.title - } - - fun lazyListStateFor( - destination: HomeDestination, - conversationFilter: ConversationFilter = ConversationFilter.All, - ): LazyListState = lazyListStateProvider.get( - key = destination.itemName + when (destination) { - Conversations -> ":$conversationFilter" // each filter has its own scroll state - else -> "" // other destinations shouldn't care about the conversation filter - } - ) - - fun closeDrawer() { - coroutineScope.launch { - drawerState.close() - } - } - - fun openDrawer() { - coroutineScope.launch { - drawerState.open() - } - } - - fun changeConversationFilter(filter: ConversationFilter) { - lazyListStateFor(Conversations, conversationFilterState.filter).requestScrollToItem(0, 0) // reset scroll for previous filter - conversationFilterState.changeFilter(filter) - } - - fun requestClearSearchOnNextResume() { - searchBarState.requestClearSearchOnNextResume() - } - - fun clearSearchOnResumeIfRequested() { - searchBarState.clearSearchOnResumeIfRequested() - } -} - -@Composable -fun rememberHomeScreenState( - navigator: Navigator, - coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberTrackingAnimatedNavController { - HomeDestination.fromRoute(it)?.itemName - }, - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed) -): HomeStateHolder { - - val searchBarState = rememberSearchbarState() - val navBackStackEntry by navController.currentBackStackEntryAsState() - - val currentNavigationItemState = remember { - derivedStateOf { - navBackStackEntry?.destination?.route?.let { HomeDestination.fromRoute(it) } ?: Conversations - } - } - - val conversationFilterState = rememberConversationFilterState() - val conversationsFilterBottomSheetState = rememberWireModalSheetState() - val newMeetingBottomSheetState = rememberWireModalSheetState() - val lazyListStateProvider = rememberLazyListStateProvider() - - return remember { - HomeStateHolder( - coroutineScope = coroutineScope, - navController = navController, - drawerState = drawerState, - searchBarState = searchBarState, - conversationsFilterBottomSheetState = conversationsFilterBottomSheetState, - newMeetingBottomSheetState = newMeetingBottomSheetState, - navigator = navigator, - currentNavigationItemState = currentNavigationItemState, - conversationFilterState = conversationFilterState, - lazyListStateProvider = lazyListStateProvider, - ) - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt index 6806c4c2728..856b5e0d309 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt @@ -22,25 +22,21 @@ import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.wire.android.datastore.UserDataStore +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.di.CurrentAccount import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.ui.common.ActionsViewModel -import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.user.SelfUser +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.client.NeedsToRegisterClientUseCase import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.personaltoteamaccount.CanMigrateFromPersonalToTeamUseCase -import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase -import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first @@ -48,20 +44,16 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @Suppress("LongParameterList") -class HomeViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, - private val dataStore: UserDataStore, +class HomeViewModel @Inject constructor( + userDataStoreProvider: UserDataStoreProvider, + @CurrentAccount private val currentAccount: UserId, private val observeSelf: ObserveSelfUserUseCase, private val needsToRegisterClient: NeedsToRegisterClientUseCase, private val canMigrateFromPersonalToTeam: CanMigrateFromPersonalToTeamUseCase, private val observeLegalHoldStatusForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, - private val currentSessionFlow: Lazy, ) : ActionsViewModel() { - @AssistedFactory - interface Factory { - fun create(savedStateHandle: SavedStateHandle): HomeViewModel - } + private val dataStore = userDataStoreProvider.getOrCreate(currentAccount) @VisibleForTesting var homeState by mutableStateOf(HomeState()) @@ -126,10 +118,9 @@ class HomeViewModel @AssistedInject constructor( fun checkRequirements() { viewModelScope.launch { val selfUser = selfUserFlow.firstOrNull() ?: return@launch - val accountInfo = currentValidAccountInfo() ?: return@launch when { needsToRegisterClient() -> // check if the client needs to be registered - sendAction(HomeRequirement.RegisterDevice(accountInfo.userId)) + sendAction(HomeRequirement.RegisterDevice(currentAccount)) !dataStore.initialSyncCompleted.first() -> // check if the initial sync needs to be completed sendAction(HomeRequirement.InitialSync) @@ -139,8 +130,4 @@ class HomeViewModel @AssistedInject constructor( } } } - - private suspend fun currentValidAccountInfo(): AccountInfo.Valid? = - (currentSessionFlow.value.invoke().firstOrNull() as? CurrentSessionResult.Success) - ?.accountInfo as? AccountInfo.Valid } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt index 8894f59174e..f863984a1a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModelGraph.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.wire.android.di.metro.MetroViewModelGraph -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.ui.home.conversationslist.ConversationListViewModel import com.wire.android.ui.home.conversationslist.ConversationListViewModelImpl import com.wire.android.ui.home.conversationslist.ConversationListViewModelPreview @@ -33,35 +33,32 @@ import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel -import dev.zacsweers.metro.Provider import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface HomeManualViewModelFactory : ManualViewModelAssistedFactory { fun conversationListViewModel(conversationsSource: ConversationsSource): ConversationListViewModelImpl } -interface HomeViewModelGraph : MetroViewModelGraph { - val featureFlagNotificationViewModel: Provider -} +interface HomeViewModelGraph : MetroViewModelGraph @Composable fun homeViewModel(): HomeViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() @Composable fun appSyncViewModel(): AppSyncViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() @Composable fun homeDrawerViewModel(): HomeDrawerViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() @Composable fun conversationListViewModel(conversationsSource: ConversationsSource): ConversationListViewModel = when { LocalInspectionMode.current -> ConversationListViewModelPreview() - else -> sessionKeyedAssistedMetroViewModel( - key = "list_$conversationsSource", - ) { + else -> wireAssistedMetroViewModel( + instanceKey = "list_$conversationsSource", + ) { _ -> conversationListViewModel(conversationsSource) } } @@ -72,10 +69,10 @@ fun newConversationViewModel( "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, ): NewConversationViewModel = - sessionKeyedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, + wireMetroViewModel( + owner = viewModelStoreOwner, ) @Composable fun featureFlagNotificationViewModel(): FeatureFlagNotificationViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3.kt new file mode 100644 index 00000000000..d407cf47651 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3.kt @@ -0,0 +1,78 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.appLock + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +internal fun resolveAppLockStartRoute( + sessionId: WireSessionId, + setTeamAppLock: Boolean, + canAuthenticateWithBiometrics: Boolean, +): SessionRoute = when { + setTeamAppLock -> SetLockCodeRoute(sessionId) + canAuthenticateWithBiometrics -> AppUnlockWithBiometricsRoute(sessionId) + else -> EnterLockCodeRoute(sessionId) +} + +@Serializable +data class SetLockCodeRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/set_lock_code_screen" + } +} + +@Serializable +data class ForgotLockCodeRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/forgot_lock_code_screen" + } +} + +@Serializable +data class EnterLockCodeRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/enter_lock_code_screen" + } +} + +@Serializable +data class AppUnlockWithBiometricsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/app_unlock_with_biometrics_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Entries.kt new file mode 100644 index 00000000000..702adabc15d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Entries.kt @@ -0,0 +1,89 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.appLock + +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.ui.home.appLock.forgot.ForgotLockCodeRouteScreen +import com.wire.android.ui.home.appLock.set.SetLockCodeRouteScreen +import com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsRouteScreen +import com.wire.android.ui.home.appLock.unlock.EnterLockCodeRouteScreen +import com.wire.android.ui.home.settings.appUnlockWithBiometricsViewModel +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand + +internal interface AppLockNavigation3Actions { + fun completeUnlock() + fun cancelUnlock() + fun restartAfterLogout() +} + +internal object AppLockNavigation3Contribution { + val resultTypes = emptyList>() + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: AppLockNavigation3Actions, + ): List = + listOf(appLockNavigation3Entries(runtime, actions)) +} + +internal fun appLockNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: AppLockNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry { + SetLockCodeRouteScreen( + onBack = { + if (!runtime.navigator.goBack()) { + actions.completeUnlock() + } + } + ) + } + wireEntry { + ForgotLockCodeRouteScreen(onLogoutCompleted = actions::restartAfterLogout) + } + wireEntry { route -> + EnterLockCodeRouteScreen( + onBack = runtime.navigator::goBack, + canNavigateBackToBiometric = runtime.navigator.routes + .dropLast(1) + .lastOrNull() is AppUnlockWithBiometricsRoute, + onForgotCode = { + runtime.navigator.navigate( + WireNavigationCommand(ForgotLockCodeRoute(route.sessionId)) + ) + }, + onUnlockCompleted = actions::completeUnlock, + ) + } + wireEntry(presentation = WireEntryPresentation.None) { route -> + val viewModel = appUnlockWithBiometricsViewModel() + AppUnlockWithBiometricsRouteScreen( + onUnlocked = { + viewModel.onAppUnlocked() + actions.completeUnlock() + }, + onCancel = actions::cancelUnlock, + onRequestPasscode = { + runtime.navigator.navigate( + WireNavigationCommand( + destination = EnterLockCodeRoute(route.sessionId), + backStackMode = WireBackStackMode.REMOVE_CURRENT, + ) + ) + }, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt index 58da7203dd9..8942c19e2dd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.appLock.forgot -import com.wire.android.navigation.annotation.app.WireRootDestination import android.content.Intent import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -48,7 +47,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign import com.wire.android.R -import com.wire.android.ui.LocalActivity import com.wire.android.ui.WireActivity import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -68,17 +66,16 @@ import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.dialogErrorStrings import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination @Composable -fun ForgotLockCodeScreen( +internal fun ForgotLockCodeRouteScreen( + onLogoutCompleted: () -> Unit, viewModel: ForgotLockScreenViewModel = forgotLockScreenViewModel(), ) { - val activity = LocalActivity.current val logoutOptionsDialogState = rememberVisibilityState() with(viewModel.state) { LaunchedEffect(completed) { if (completed) { - startLoginActivity(activity) + onLogoutCompleted() } } ForgotLockCodeScreenContent( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt index 004c7fef9ad..a74b23d5d90 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.appLock.set -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -50,8 +49,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.ImeAction import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions @@ -74,18 +71,17 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import androidx.compose.ui.platform.LocalLocale -@WireRootDestination @Composable -fun SetLockCodeScreen( - navigator: Navigator, +internal fun SetLockCodeRouteScreen( + onBack: () -> Unit, viewModel: SetLockScreenViewModel = setLockScreenViewModel(), ) { SetLockCodeScreenContent( - navigator = navigator, state = viewModel.state, passwordTextState = viewModel.passwordTextState, scrollState = rememberScrollState(), - onBackPress = navigator::navigateBack, + onBackPress = onBack, + onDone = onBack, onContinue = viewModel::onContinue ) } @@ -93,17 +89,17 @@ fun SetLockCodeScreen( @OptIn(ExperimentalComposeUiApi::class) @Composable fun SetLockCodeScreenContent( - navigator: Navigator, state: SetLockCodeViewState, passwordTextState: TextFieldState, scrollState: ScrollState, onBackPress: () -> Unit, + onDone: () -> Unit, onContinue: () -> Unit, modifier: Modifier = Modifier, ) { LaunchedEffect(state.done) { if (state.done) { - navigator.navigateBack() + onDone() } } @@ -274,11 +270,11 @@ fun PreviewPasswordVerificationGroup() { fun PreviewSetLockCodeScreen() { WireTheme { SetLockCodeScreenContent( - navigator = rememberNavigator {}, state = SetLockCodeViewState(), passwordTextState = TextFieldState(), scrollState = rememberScrollState(), onBackPress = {}, + onDone = {}, onContinue = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt index b5f8427809c..273c4fc6da6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt @@ -17,8 +17,8 @@ */ package com.wire.android.ui.home.appLock.unlock -import com.wire.android.navigation.annotation.app.WireRootDestination import android.widget.Toast +import androidx.activity.compose.LocalActivity import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -31,61 +31,44 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import com.wire.android.R import com.wire.android.appLogger import com.wire.android.biometric.showBiometricPrompt -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions -import com.ramcosta.composedestinations.generated.app.destinations.EnterLockCodeScreenDestination -import com.wire.android.ui.home.settings.appUnlockWithBiometricsViewModel -@WireRootDestination @Composable -fun AppUnlockWithBiometricsScreen( - navigator: Navigator, - appUnlockWithBiometricsViewModel: AppUnlockWithBiometricsViewModel = appUnlockWithBiometricsViewModel() +internal fun AppUnlockWithBiometricsRouteScreen( + onUnlocked: () -> Unit, + onCancel: () -> Unit, + onRequestPasscode: () -> Unit, ) { AppUnLockBackground() - val context = LocalContext.current + val activity = LocalActivity.current as AppCompatActivity val tooManyAttemptsMessage = stringResource( id = R.string.biometrics_app_unlock_too_many_attempts ) LaunchedEffect(Unit) { - (context as AppCompatActivity).showBiometricPrompt( + activity.showBiometricPrompt( onSuccess = { appLogger.i("appLock: app Unlocked with biometrics") - appUnlockWithBiometricsViewModel.onAppUnlocked() - navigator.navigateBack() + onUnlocked() }, onCancel = { appLogger.i("appLock: biometrics unlock canceled") - context.finishAffinity() + onCancel() }, onTooManyFailedAttempts = { - Toast.makeText(context, tooManyAttemptsMessage, Toast.LENGTH_SHORT).show() - navigator.navigate( - NavigationCommand( - destination = EnterLockCodeScreenDestination, - backStackMode = BackStackMode.REMOVE_CURRENT - ) - ) + Toast.makeText(activity, tooManyAttemptsMessage, Toast.LENGTH_SHORT).show() + onRequestPasscode() }, onRequestPasscode = { appLogger.i("appLock: requesting passcode from biometrics unlock") - navigator.navigate( - NavigationCommand( - destination = EnterLockCodeScreenDestination, - backStackMode = BackStackMode.REMOVE_CURRENT - ) - ) + onRequestPasscode() } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt index 399595cad90..2a5a9f95838 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.appLock.unlock -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -48,10 +47,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.ImeAction -import com.ramcosta.composedestinations.utils.destination import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireTertiaryButton @@ -60,8 +56,6 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.textfield.DefaultPassword import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextFieldState -import com.ramcosta.composedestinations.generated.app.destinations.AppUnlockWithBiometricsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ForgotLockCodeScreenDestination import com.wire.android.ui.home.settings.enterLockScreenViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -70,10 +64,12 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import androidx.compose.ui.platform.LocalLocale -@WireRootDestination @Composable -fun EnterLockCodeScreen( - navigator: Navigator, +internal fun EnterLockCodeRouteScreen( + onBack: () -> Unit, + canNavigateBackToBiometric: Boolean, + onForgotCode: () -> Unit, + onUnlockCompleted: () -> Unit = onBack, viewModel: EnterLockScreenViewModel = enterLockScreenViewModel(), ) { EnterLockCodeScreenContent( @@ -81,15 +77,15 @@ fun EnterLockCodeScreen( passwordTextState = viewModel.passwordTextState, scrollState = rememberScrollState(), onContinue = viewModel::onContinue, - onForgotCodeClicked = { navigator.navigate(NavigationCommand(ForgotLockCodeScreenDestination)) } + onForgotCodeClicked = onForgotCode, ) BackHandler { - if (navigator.navController.previousBackStackEntry?.destination() is AppUnlockWithBiometricsScreenDestination) { - navigator.navigateBack() + if (canNavigateBackToBiometric) { + onBack() } } LaunchedEffect(viewModel.state.done) { - if (viewModel.state.done) navigator.navigateBack() + if (viewModel.state.done) onUnlockCompleted() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt index 98570e4d017..447ea0519b2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt @@ -18,33 +18,34 @@ package com.wire.android.ui.home.archive -import com.wire.android.navigation.annotation.app.WireHomeDestination import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.search.rememberSearchbarState -import com.wire.android.ui.home.HomeStateHolder +import com.wire.android.ui.home.HomeShellState import com.wire.android.ui.home.conversationslist.ConversationListViewModelPreview +import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions import com.wire.android.ui.home.conversationslist.ConversationsScreenContent import com.wire.android.ui.home.conversationslist.common.previewConversationItemsFlow import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes -@WireHomeDestination @Composable -fun ArchiveScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { +internal fun ArchiveScreen( + homeShellState: HomeShellState, + navigationActions: ConversationsNavigationActions, +) { + with(homeShellState) { ConversationsScreenContent( - navigator = navigator, + navigationActions = navigationActions, searchBarState = searchBarState, conversationsSource = ConversationsSource.ARCHIVE, lazyListState = lazyListStateFor(HomeDestination.Archive), emptySearchResultFocusRequester = emptySearchResultFocusRequester, firstConversationFocusRequester = firstConversationFocusRequester, - onConversationOpened = homeStateHolder::requestClearSearchOnNextResume, - emptyListContent = { ArchiveEmptyContent() } + onConversationOpened = ::requestClearSearchOnNextResume, + emptyListContent = { ArchiveEmptyContent() }, ) } } @@ -53,7 +54,7 @@ fun ArchiveScreen(homeStateHolder: HomeStateHolder) { @Composable fun PreviewArchiveEmptyScreen() = WireTheme { ConversationsScreenContent( - navigator = rememberNavigator {}, + navigationActions = previewConversationsNavigationActions(), searchBarState = rememberSearchbarState(), conversationsSource = ConversationsSource.ARCHIVE, emptyListContent = { ArchiveEmptyContent() }, @@ -65,7 +66,7 @@ fun PreviewArchiveEmptyScreen() = WireTheme { @Composable fun PreviewArchiveEmptySearchScreen() = WireTheme { ConversationsScreenContent( - navigator = rememberNavigator {}, + navigationActions = previewConversationsNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.ARCHIVE, emptyListContent = { ArchiveEmptyContent() }, @@ -77,10 +78,20 @@ fun PreviewArchiveEmptySearchScreen() = WireTheme { @Composable fun PreviewArchiveScreen() = WireTheme { ConversationsScreenContent( - navigator = rememberNavigator {}, + navigationActions = previewConversationsNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.ARCHIVE, emptyListContent = { ArchiveEmptyContent() }, conversationListViewModel = ConversationListViewModelPreview(previewConversationItemsFlow(searchQuery = "er")), ) } + +private fun previewConversationsNavigationActions() = ConversationsNavigationActions( + openConversation = {}, + openUserProfile = {}, + startConversation = {}, + browseChannels = {}, + openConversationFolders = {}, + promoteAdmin = {}, + openDebugMenu = {}, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/cell/GlobalCellsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/cell/GlobalCellsScreen.kt index 2cfbd6a9864..c16dfb8f216 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/cell/GlobalCellsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/cell/GlobalCellsScreen.kt @@ -20,22 +20,21 @@ package com.wire.android.ui.home.cell import android.annotation.SuppressLint import androidx.compose.runtime.Composable import com.wire.android.feature.cells.ui.AllFilesScreen +import com.wire.android.feature.cells.ui.AllFilesNavigationActions import com.wire.android.feature.cells.ui.CellViewModel -import com.wire.android.navigation.annotation.app.WireHomeDestination -import com.wire.android.ui.home.HomeStateHolder -// Forwarding is intentional: CellViewModel is scoped to HomeScreen's back stack entry -// (via dependenciesContainerBuilder in HomeScreen) so that AllFilesScreen and SearchScreen -// share the same ViewModel instance across navigation. +/** + * Navigation-neutral Global Cells renderer. [viewModel] is owned by the Home Navigation 3 entry, + * preserving the existing sharing contract without looking up a Nav2 parent back-stack entry. + */ @SuppressLint("ComposeViewModelForwarding") -@WireHomeDestination @Composable -fun GlobalCellsScreen( - homeStateHolder: HomeStateHolder, +internal fun GlobalCellsScreen( + navigationActions: AllFilesNavigationActions, viewModel: CellViewModel, ) { AllFilesScreen( - navigator = homeStateHolder.navigator, + navigationActions = navigationActions, viewModel = viewModel, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModel.kt index 60edb45f652..a21ea4e0399 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModel.kt @@ -17,24 +17,22 @@ */ package com.wire.android.ui.home.conversations -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject - import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.di.ViewModelScopedPreview import com.wire.android.ui.home.conversations.model.CompositeMessageArgs import com.wire.kalium.logic.data.id.MessageButtonId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch + @ViewModelScopedPreview interface CompositeMessageViewModel { val pendingButtonId: MessageButtonId? @@ -44,16 +42,14 @@ interface CompositeMessageViewModel { class CompositeMessageViewModelImpl @AssistedInject constructor( private val sendButtonActionMessageUseCase: SendButtonActionMessageUseCase, - @Assisted savedStateHandle: SavedStateHandle, @Assisted scopedArgs: CompositeMessageArgs, ) : CompositeMessageViewModel, ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle, scopedArgs: CompositeMessageArgs): CompositeMessageViewModelImpl + fun create(scopedArgs: CompositeMessageArgs): CompositeMessageViewModelImpl } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() - val conversationId: QualifiedID = conversationNavArgs.conversationId + val conversationId: QualifiedID = scopedArgs.conversationId private val messageId: String = scopedArgs.messageId override var pendingButtonId: MessageButtonId? by mutableStateOf(null) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3.kt new file mode 100644 index 00000000000..a20434b152d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3.kt @@ -0,0 +1,188 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationAuxId(val value: String, val domain: String) { + init { + require(value.isNotBlank()) + require(domain.isNotBlank()) + } +} + +@Serializable +data class BrowseChannelsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/browse_channels_screen" + } +} + +@Serializable +data class ConversationFoldersRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationAuxId, + val conversationName: String, + val currentFolderId: String?, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/conversation_folders_screen" + } +} + +@Serializable +data class NewConversationFolderRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + init { + require(flowId.isNotBlank()) + } + + companion object { + const val ROUTE_ID = "app/new_conversation_folder_screen" + } +} + +@Serializable +data class SearchConversationMessagesRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationAuxId, + val groupName: String = "", + val isCellsConversation: Boolean = false, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/search_conversation_messages_screen" + } +} + +@Serializable +data class PromoteAdminRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationAuxId, + val eligibleMembers: List = emptyList(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/promote_admin_screen" + } +} + +@Serializable +sealed interface ConversationProtocolSelection { + @Serializable + data object Proteus : ConversationProtocolSelection + + @Serializable + sealed interface MlsCapable : ConversationProtocolSelection { + val groupId: String + val groupState: GroupState + val epoch: ULong + val keyingMaterialLastUpdate: String + val cipherSuiteTag: Int + } + + @Serializable + data class Mls( + override val groupId: String, + override val groupState: GroupState, + override val epoch: ULong, + override val keyingMaterialLastUpdate: String, + override val cipherSuiteTag: Int, + ) : MlsCapable + + @Serializable + data class Mixed( + override val groupId: String, + override val groupState: GroupState, + override val epoch: ULong, + override val keyingMaterialLastUpdate: String, + override val cipherSuiteTag: Int, + ) : MlsCapable + + @Serializable + enum class GroupState { + PENDING_CREATION, + PENDING_JOIN, + PENDING_WELCOME_MESSAGE, + ESTABLISHED, + PENDING_AFTER_RESET, + } +} + +@Serializable +data class AddMembersSearchRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationAuxId, + val isConversationAppsEnabled: Boolean, + val isSelfPartOfATeam: Boolean, + val protocol: ConversationProtocolSelection, + val shouldUseNewAppsUi: Boolean = true, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/add_members_search_screen" + } +} + +@Serializable +data class DebugConversationRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationAuxId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/debug_conversation_screen" + } +} + +@Serializable +data class ConversationFoldersResult(val message: String) + +internal val ConversationFoldersResultContract = WireNavResultContract( + WireNavResultContractId("conversation-folders.completed") +) + +@Serializable +data class NewConversationFolderResult( + val folderName: String, + val folderId: String, +) + +internal val NewConversationFolderResultContract = WireNavResultContract( + WireNavResultContractId("conversation-folders.created") +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Entries.kt new file mode 100644 index 00000000000..fb121654148 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Entries.kt @@ -0,0 +1,198 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.model.Contact +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.ui.debug.conversation.DebugConversationRouteScreen +import com.wire.android.ui.debug.debugConversationViewModel +import com.wire.android.ui.home.conversations.channels.BrowseChannelsRouteScreen +import com.wire.android.ui.home.conversations.folder.ConversationFoldersRouteScreen +import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderArgs +import com.wire.android.ui.home.conversations.folder.NewConversationFolderRouteScreen +import com.wire.android.ui.home.conversations.folder.NewFolderViewModel +import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminRouteScreen +import com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersSearchRouteScreen +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesRouteScreen +import com.wire.kalium.logic.data.conversation.FolderType +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId + +internal val ConversationFoldersNavigation3ResultType = WireNavigation3ResultType( + ConversationFoldersResultContract, + ConversationFoldersResult.serializer(), +) + +internal val NewConversationFolderNavigation3ResultType = WireNavigation3ResultType( + NewConversationFolderResultContract, + NewConversationFolderResult.serializer(), +) + +internal interface ConversationAuxNavigation3Actions { + fun openConversationAtMessage(conversationId: ConversationAuxId, messageId: String) + fun openCellsSearch(conversationId: ConversationAuxId) + fun openUserProfile(userId: ConversationAuxId) + fun openService( + conversationId: ConversationAuxId, + serviceId: ConversationAuxId, + shouldUseNewAppsUi: Boolean, + ) +} + +internal object ConversationAuxNavigation3Contribution { + val resultTypes: List> = listOf( + ConversationFoldersNavigation3ResultType, + NewConversationFolderNavigation3ResultType, + ) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: ConversationAuxNavigation3Actions, + ): List = listOf( + conversationAuxNavigation3Entries(runtime, actions) + ) +} + +internal fun conversationAuxNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: ConversationAuxNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { + BrowseChannelsRouteScreen(runtime.navigator::goBack) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + ConversationFoldersNavigation3Entry(it, runtime) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + NewConversationFolderNavigation3Entry(runtime, newFolderViewModel()) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + SearchConversationMessagesRouteScreen( + viewModel = searchConversationMessagesViewModel(it.toViewModelArgs()), + onMessageClick = { messageId -> actions.openConversationAtMessage(it.conversationId, messageId) }, + onCloseSearchClicked = runtime.navigator::goBack, + onSearchFilesButtonClick = { actions.openCellsSearch(it.conversationId) }, + ) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + PromoteAdminRouteScreen( + viewModel = promoteAdminViewModel(it.toViewModelArgs()), + onNavigateBack = runtime.navigator::goBack, + ) + } + wireEntry { + AddMembersSearchRouteScreen( + viewModel = addMembersToConversationViewModel(it.toViewModelArgs()), + navArgs = it.toViewModelArgs(), + onNavigateBack = runtime.navigator::goBack, + onOpenUserProfile = { contact -> actions.openUserProfile(contact.toRouteId()) }, + onOpenService = { contact -> + actions.openService(it.conversationId, contact.toRouteId(), it.shouldUseNewAppsUi) + }, + ) + } + wireEntry { + DebugConversationRouteScreen( + viewModel = debugConversationViewModel(it.toViewModelArgs()), + onNavigateBack = runtime.navigator::goBack, + ) + } +} + +@Composable +private fun ConversationFoldersNavigation3Entry( + route: ConversationFoldersRoute, + runtime: WireNavigation3Runtime, +) { + val args = route.toViewModelArgs() + val foldersViewModel = conversationFoldersViewModel(ConversationFoldersStateArgs(route.currentFolderId)) + val moveViewModel = moveConversationToFolderViewModel( + MoveConversationToFolderArgs(args.conversationId, args.conversationName, args.currentFolderId) + ) + var pendingFolderRequest by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + val currentEntryId = runtime.navigator.currentRoute?.entryId + + LaunchedEffect(pendingFolderRequest, currentEntryId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + val requestId = pendingFolderRequest?.let(::WireNavResultRequestId) ?: return@LaunchedEffect + when (val result = runtime.consumeResult(requestId, NewConversationFolderNavigation3ResultType)) { + is WireNavResult.Value -> { + moveViewModel.moveConversationToFolder( + com.wire.kalium.logic.data.conversation.ConversationFolder( + result.value.folderId, + result.value.folderName, + FolderType.USER, + ) + ) + pendingFolderRequest = null + } + WireNavResult.Canceled -> pendingFolderRequest = null + null -> Unit + } + } + + ConversationFoldersRouteScreen( + args = args, + foldersViewModel = foldersViewModel, + moveToFolderViewModel = moveViewModel, + onNavigateBack = runtime.navigator::goBack, + onCompleted = { message -> + if (!runtime.completeCurrentAndPop( + ConversationFoldersNavigation3ResultType, + WireNavResult.Value(ConversationFoldersResult(message)), + ) + ) { + runtime.navigator.goBack() + } + }, + onCreateFolderPressed = { + pendingFolderRequest = runtime.navigateForResult( + NewConversationFolderRoute(route.sessionId, flowId = route.entryId.value), + NewConversationFolderNavigation3ResultType, + )?.value + }, + ) +} + +@Suppress("ComposeViewModelForwarding") +@Composable +private fun NewConversationFolderNavigation3Entry( + runtime: WireNavigation3Runtime, + viewModel: NewFolderViewModel, +) { + NewConversationFolderRouteScreen( + viewModel = viewModel, + onBackPressed = runtime.navigator::goBack, + onFolderCreated = { + val result = NewConversationFolderResult(it.folderName, it.folderId) + if (!runtime.completeCurrentAndPop( + NewConversationFolderNavigation3ResultType, + WireNavResult.Value(result), + ) + ) { + runtime.navigator.goBack() + } + }, + ) +} + +private fun Contact.toRouteId() = ConversationAuxId(id, domain) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Mappers.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Mappers.kt new file mode 100644 index 00000000000..c94ae8bbf9c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Mappers.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations + +import com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs +import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs +import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesNavArgs +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.GroupID +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.mls.CipherSuite +import kotlinx.datetime.Instant + +internal fun ConversationAuxId.toQualifiedId() = QualifiedID(value, domain) +internal fun QualifiedID.toConversationAuxId() = ConversationAuxId(value, domain) + +internal fun ConversationFoldersRoute.toViewModelArgs() = ConversationFoldersNavArgs( + conversationId = conversationId.toQualifiedId(), + conversationName = conversationName, + currentFolderId = currentFolderId, +) + +internal fun SearchConversationMessagesRoute.toViewModelArgs() = SearchConversationMessagesNavArgs( + conversationId = conversationId.toQualifiedId(), + groupName = groupName, + isCellsConversation = isCellsConversation, +) + +internal fun PromoteAdminRoute.toViewModelArgs() = PromoteAdminNavArgs( + conversationId = conversationId.toQualifiedId(), + eligibleMembers = eligibleMembers + .mapTo(ArrayList()) { "${it.value}@${it.domain}" }, +) + +internal fun AddMembersSearchRoute.toViewModelArgs() = AddMembersSearchNavArgs( + conversationId = conversationId.toQualifiedId(), + isConversationAppsEnabled = isConversationAppsEnabled, + isSelfPartOfATeam = isSelfPartOfATeam, + protocolInfo = protocol.toLegacy(), + shouldUseNewAppsUi = shouldUseNewAppsUi, +) + +internal fun DebugConversationRoute.toViewModelArgs() = + DebugConversationScreenNavArgs(conversationId.toQualifiedId()) + +internal fun Conversation.ProtocolInfo.toNavigation3(): ConversationProtocolSelection = when (this) { + Conversation.ProtocolInfo.Proteus -> ConversationProtocolSelection.Proteus + is Conversation.ProtocolInfo.MLS -> ConversationProtocolSelection.Mls( + groupId.value, + groupState.toNavigation3(), + epoch, + keyingMaterialLastUpdate.toString(), + cipherSuite.tag, + ) + is Conversation.ProtocolInfo.Mixed -> ConversationProtocolSelection.Mixed( + groupId.value, + groupState.toNavigation3(), + epoch, + keyingMaterialLastUpdate.toString(), + cipherSuite.tag, + ) +} + +internal fun ConversationProtocolSelection.toLegacy(): Conversation.ProtocolInfo = when (this) { + ConversationProtocolSelection.Proteus -> Conversation.ProtocolInfo.Proteus + is ConversationProtocolSelection.Mls -> Conversation.ProtocolInfo.MLS( + GroupID(groupId), + groupState.toLegacy(), + epoch, + Instant.parse(keyingMaterialLastUpdate), + CipherSuite.fromTag(cipherSuiteTag), + ) + is ConversationProtocolSelection.Mixed -> Conversation.ProtocolInfo.Mixed( + GroupID(groupId), + groupState.toLegacy(), + epoch, + Instant.parse(keyingMaterialLastUpdate), + CipherSuite.fromTag(cipherSuiteTag), + ) +} + +private fun Conversation.ProtocolInfo.MLSCapable.GroupState.toNavigation3() = + ConversationProtocolSelection.GroupState.valueOf(name) + +private fun ConversationProtocolSelection.GroupState.toLegacy() = + Conversation.ProtocolInfo.MLSCapable.GroupState.valueOf(name) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelGraph.kt index 01a2d344f9c..419836271dc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationCoreViewModelGraph.kt @@ -22,16 +22,19 @@ package com.wire.android.ui.home.conversations import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.ViewModel -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel import com.wire.kalium.logic.data.id.ConversationId import com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel import com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel import com.wire.android.ui.home.conversations.composer.MessageComposerViewModel import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.media.ConversationAssetMessagesViewModel +import com.wire.android.ui.home.conversations.media.ConversationMediaNavArgs import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewViewModel +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavArgs import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsViewModel +import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsNavArgs import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.QuotedMultipartMessageViewModel import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel @@ -43,37 +46,86 @@ import com.wire.android.ui.home.conversations.model.messagetypes.multipart.Multi import com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsViewModelImpl import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel import com.wire.android.ui.home.gallery.MediaGalleryViewModel +import com.wire.android.ui.home.gallery.MediaGalleryNavArgs import com.wire.android.ui.home.messagecomposer.location.LocationPickerViewModel import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface ConversationCoreManualViewModelFactory : ManualViewModelAssistedFactory { + fun conversationMessagesViewModel(args: ConversationNavArgs): ConversationMessagesViewModel + fun messageComposerViewModel(args: ConversationNavArgs): MessageComposerViewModel + fun sendMessageViewModel(args: ConversationNavArgs): SendMessageViewModel + fun messageDraftViewModel(args: ConversationNavArgs): MessageDraftViewModel + fun messageAttachmentsViewModel(args: ConversationNavArgs): MessageAttachmentsViewModel + fun conversationMigrationViewModel(args: ConversationNavArgs): ConversationMigrationViewModel + fun conversationBannerViewModel(args: ConversationNavArgs): ConversationBannerViewModel + fun conversationInfoViewModel(args: ConversationNavArgs): ConversationInfoViewModel fun multipartAttachmentsViewModel(conversationId: ConversationId): MultipartAttachmentsViewModelImpl + fun mediaGalleryViewModel(args: MediaGalleryNavArgs): MediaGalleryViewModel + fun conversationAssetMessagesViewModel(args: ConversationMediaNavArgs): ConversationAssetMessagesViewModel + fun imagesPreviewViewModel(args: ImagesPreviewNavArgs): ImagesPreviewViewModel + fun messageDetailsViewModel(args: MessageDetailsNavArgs): MessageDetailsViewModel } @Composable fun conversationMessagesViewModel(): ConversationMessagesViewModel = conversationCoreViewModel() +@Composable +fun conversationMessagesViewModel(args: ConversationNavArgs): ConversationMessagesViewModel = + wireAssistedMetroViewModel { _ -> + conversationMessagesViewModel(args) + } + @Composable fun messageComposerViewModel(): MessageComposerViewModel = conversationCoreViewModel() +@Composable +fun messageComposerViewModel(args: ConversationNavArgs): MessageComposerViewModel = + wireAssistedMetroViewModel { _ -> + messageComposerViewModel(args) + } + @Composable fun sendMessageViewModel(): SendMessageViewModel = conversationCoreViewModel() +@Composable +fun sendMessageViewModel(args: ConversationNavArgs): SendMessageViewModel = + wireAssistedMetroViewModel { _ -> + sendMessageViewModel(args) + } + @Composable fun messageDraftViewModel(): MessageDraftViewModel = conversationCoreViewModel() +@Composable +fun messageDraftViewModel(args: ConversationNavArgs): MessageDraftViewModel = + wireAssistedMetroViewModel { _ -> + messageDraftViewModel(args) + } + @Composable fun messageAttachmentsViewModel(): MessageAttachmentsViewModel = conversationCoreViewModel() +@Composable +fun messageAttachmentsViewModel(args: ConversationNavArgs): MessageAttachmentsViewModel = + wireAssistedMetroViewModel { _ -> + messageAttachmentsViewModel(args) + } + @Composable fun conversationMigrationViewModel(): ConversationMigrationViewModel = conversationCoreViewModel() +@Composable +fun conversationMigrationViewModel(args: ConversationNavArgs): ConversationMigrationViewModel = + wireAssistedMetroViewModel { _ -> + conversationMigrationViewModel(args) + } + @Composable fun conversationAssetPathsViewModel(conversationKey: String): ConversationAssetPathsViewModel = when { LocalInspectionMode.current -> ConversationAssetPathsViewModelPreview @@ -84,6 +136,12 @@ fun conversationAssetPathsViewModel(conversationKey: String): ConversationAssetP fun mediaGalleryViewModel(): MediaGalleryViewModel = conversationCoreViewModel() +@Composable +fun mediaGalleryViewModel(args: MediaGalleryNavArgs): MediaGalleryViewModel = + wireAssistedMetroViewModel { _ -> + mediaGalleryViewModel(args) + } + @Composable fun locationPickerViewModel(): LocationPickerViewModel = conversationCoreViewModel() @@ -92,14 +150,32 @@ fun locationPickerViewModel(): LocationPickerViewModel = fun conversationAssetMessagesViewModel(): ConversationAssetMessagesViewModel = conversationCoreViewModel() +@Composable +fun conversationAssetMessagesViewModel(args: ConversationMediaNavArgs): ConversationAssetMessagesViewModel = + wireAssistedMetroViewModel { _ -> + conversationAssetMessagesViewModel(args) + } + @Composable fun imagesPreviewViewModel(): ImagesPreviewViewModel = conversationCoreViewModel() +@Composable +fun imagesPreviewViewModel(args: ImagesPreviewNavArgs): ImagesPreviewViewModel = + wireAssistedMetroViewModel { _ -> + imagesPreviewViewModel(args) + } + @Composable fun messageDetailsViewModel(): MessageDetailsViewModel = conversationCoreViewModel() +@Composable +fun messageDetailsViewModel(args: MessageDetailsNavArgs): MessageDetailsViewModel = + wireAssistedMetroViewModel { _ -> + messageDetailsViewModel(args) + } + @Composable fun quotedMultipartMessageViewModel(conversationKey: String): QuotedMultipartMessageViewModel = conversationCoreViewModel(key = conversationKey) @@ -108,15 +184,27 @@ fun quotedMultipartMessageViewModel(conversationKey: String): QuotedMultipartMes fun conversationBannerViewModel(): ConversationBannerViewModel = conversationCoreViewModel() +@Composable +fun conversationBannerViewModel(args: ConversationNavArgs): ConversationBannerViewModel = + wireAssistedMetroViewModel { _ -> + conversationBannerViewModel(args) + } + @Composable fun conversationInfoViewModel(): ConversationInfoViewModel = conversationCoreViewModel() +@Composable +fun conversationInfoViewModel(args: ConversationNavArgs): ConversationInfoViewModel = + wireAssistedMetroViewModel { _ -> + conversationInfoViewModel(args) + } + @Composable fun multipartAttachmentsViewModel(conversationId: ConversationId): MultipartAttachmentsViewModel = - sessionKeyedAssistedMetroViewModel( - key = conversationId.value, - ) { + wireAssistedMetroViewModel( + instanceKey = conversationId.value, + ) { _ -> multipartAttachmentsViewModel(conversationId) } @@ -124,4 +212,4 @@ fun multipartAttachmentsViewModel(conversationId: ConversationId): MultipartAtta private inline fun conversationCoreViewModel( key: String? = null, ): VM where VM : ViewModel = - sessionKeyedMetroViewModel(key) + wireMetroViewModel(instanceKey = key) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationDetailsViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationDetailsViewModelGraph.kt index 8f8003bb64a..8561dceafcb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationDetailsViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationDetailsViewModelGraph.kt @@ -15,52 +15,128 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("TooManyFunctions", "MatchingDeclarationName") + package com.wire.android.ui.home.conversations import androidx.compose.runtime.Composable -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import androidx.lifecycle.ViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel +import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavArgs import com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessViewModel +import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessNavArgs +import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkNavArgs import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel import com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesViewModel +import com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesNavArgs import com.wire.android.ui.home.conversations.details.metadata.EditConversationMetadataViewModel +import com.wire.android.ui.home.conversations.details.metadata.EditConversationNameNavArgs import com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsViewModel +import com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsNavArgs import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessViewModel +import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessNavArgs import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessViewModel +import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessArgs import com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory + +interface ConversationDetailsManualViewModelFactory : ManualViewModelAssistedFactory { + fun groupConversationDetailsViewModel(args: GroupConversationDetailsNavArgs): GroupConversationDetailsViewModel + fun groupConversationParticipantsViewModel( + args: GroupConversationAllParticipantsNavArgs, + ): GroupConversationParticipantsViewModel + + fun editConversationMetadataViewModel(args: EditConversationNameNavArgs): EditConversationMetadataViewModel + fun editSelfDeletingMessagesViewModel( + args: EditSelfDeletingMessagesNavArgs, + ): EditSelfDeletingMessagesViewModel + + fun updateChannelAccessViewModel(args: UpdateChannelAccessArgs): UpdateChannelAccessViewModel + fun updateAppsAccessViewModel(args: UpdateAppsAccessNavArgs): UpdateAppsAccessViewModel + fun editGuestAccessViewModel(args: EditGuestAccessNavArgs): EditGuestAccessViewModel + fun createPasswordGuestLinkViewModel(args: CreatePasswordGuestLinkNavArgs): CreatePasswordGuestLinkViewModel +} @Composable fun groupConversationDetailsViewModel(): GroupConversationDetailsViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun groupConversationDetailsViewModel(args: GroupConversationDetailsNavArgs): GroupConversationDetailsViewModel = + conversationDetailsAssistedViewModel { groupConversationDetailsViewModel(args) } @Composable fun groupConversationParticipantsViewModel(): GroupConversationParticipantsViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun groupConversationParticipantsViewModel( + args: GroupConversationAllParticipantsNavArgs, +): GroupConversationParticipantsViewModel = + conversationDetailsAssistedViewModel { groupConversationParticipantsViewModel(args) } @Composable fun editConversationMetadataViewModel(): EditConversationMetadataViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun editConversationMetadataViewModel(args: EditConversationNameNavArgs): EditConversationMetadataViewModel = + conversationDetailsAssistedViewModel { editConversationMetadataViewModel(args) } @Composable fun editSelfDeletingMessagesViewModel(): EditSelfDeletingMessagesViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun editSelfDeletingMessagesViewModel( + args: EditSelfDeletingMessagesNavArgs, +): EditSelfDeletingMessagesViewModel = + conversationDetailsAssistedViewModel { editSelfDeletingMessagesViewModel(args) } @Composable fun updateChannelAccessViewModel(): UpdateChannelAccessViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun updateChannelAccessViewModel(args: UpdateChannelAccessArgs): UpdateChannelAccessViewModel = + conversationDetailsAssistedViewModel { updateChannelAccessViewModel(args) } @Composable fun updateAppsAccessViewModel(): UpdateAppsAccessViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun updateAppsAccessViewModel(args: UpdateAppsAccessNavArgs): UpdateAppsAccessViewModel = + conversationDetailsAssistedViewModel { updateAppsAccessViewModel(args) } @Composable fun editGuestAccessViewModel(): EditGuestAccessViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun editGuestAccessViewModel(args: EditGuestAccessNavArgs): EditGuestAccessViewModel = + conversationDetailsAssistedViewModel { editGuestAccessViewModel(args) } @Composable fun createPasswordGuestLinkViewModel(): CreatePasswordGuestLinkViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +fun createPasswordGuestLinkViewModel( + args: CreatePasswordGuestLinkNavArgs, +): CreatePasswordGuestLinkViewModel = + conversationDetailsAssistedViewModel { createPasswordGuestLinkViewModel(args) } @Composable fun checkAssetRestrictionsViewModel(): CheckAssetRestrictionsViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() + +@Composable +private inline fun conversationDetailsAssistedViewModel( + crossinline create: ConversationDetailsManualViewModelFactory.() -> VM, +): VM where VM : ViewModel = + wireAssistedMetroViewModel( + create = { _ -> create() }, + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3.kt new file mode 100644 index 00000000000..576ffe1ff66 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3.kt @@ -0,0 +1,73 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationRouteId( + val value: String, + val domain: String, +) { + init { + require(value.isNotBlank()) + require(domain.isNotBlank()) + } +} + +@Serializable +data class ConversationPendingAsset( + val key: String, + val mimeType: String, + val dataPath: String, + val dataSize: Long, + val fileName: String, + val assetType: String, + val audioWavesMask: List? = null, +) + +@Serializable +data class ConversationRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationRouteId, + val searchedMessageId: String? = null, + val pendingAssets: List = emptyList(), + val pendingText: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/conversation_screen" + } +} + +@Serializable +enum class ConversationCompletionAction { + LEAVE_GROUP, + DELETE_GROUP, +} + +@Serializable +data class ConversationCompletionResult( + val action: ConversationCompletionAction, + val conversationName: String, +) + +internal val ConversationCompletionResultContract = + WireNavResultContract( + WireNavResultContractId("conversation.completion") + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt new file mode 100644 index 00000000000..0f09cdbda64 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt @@ -0,0 +1,345 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.ui.home.conversations + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.feature.sketch.navigation.DrawingCanvasNavigation3ResultType +import com.wire.android.feature.sketch.navigation.DrawingCanvasRoute +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.routes.media.ImagesPreviewNavigation3ResultType +import com.wire.android.navigation.routes.media.AuthenticatedImportMediaRoute +import com.wire.android.navigation.routes.media.ImagesPreviewResult +import com.wire.android.navigation.routes.media.ImagesPreviewRoute +import com.wire.android.navigation.routes.media.MediaConversationId +import com.wire.android.navigation.routes.media.MediaGalleryNavigation3ResultType +import com.wire.android.navigation.routes.media.MediaGalleryResult +import com.wire.android.navigation.routes.media.MediaGalleryResultAction +import com.wire.android.navigation.routes.media.MediaGalleryRoute +import com.wire.android.navigation.routes.media.VideoPlayerRoute +import com.wire.android.navigation.routes.media.MessageDetailsRoute +import com.wire.android.navigation.routes.media.toLegacy +import com.wire.android.ui.calling.conversationCallViewModel +import com.wire.android.ui.home.conversations.details.ConversationDetailsId +import com.wire.android.ui.home.conversations.details.GroupConversationActionType +import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs +import com.wire.android.ui.home.conversations.details.GroupConversationDetailsRoute +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs +import com.wire.android.ui.home.gallery.MediaGalleryActionType +import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.android.ui.userprofile.self.SelfUserProfileRoute +import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs +import com.wire.android.ui.userprofile.service.toServiceDetailsRoute +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireNavigationCommand + +internal val ConversationCompletionNavigation3ResultType = WireNavigation3ResultType( + ConversationCompletionResultContract, + ConversationCompletionResult.serializer(), +) + +/** + * The drawing destination is owned by a feature module that is migrated independently. + * This explicit semantic bridge keeps its generated destination out of the Navigation 3 entry. + */ +internal interface ConversationNavigation3Actions { + fun exitConversation() + fun completeConversation(result: ConversationCompletionResult) +} + +internal object ConversationNavigation3Contribution { + val resultTypes: List> = + listOf(ConversationCompletionNavigation3ResultType) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: ConversationNavigation3Actions, + ): List = listOf(conversationNavigation3Entries(runtime, actions)) +} + +internal fun conversationNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: ConversationNavigation3Actions, +): WireEntryProviderInstaller = { + // ConversationScreen had no destination override and inherited WireRoot's horizontal motion. + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + ConversationNavigation3Entry(route, runtime, actions) + } +} + +@Composable +private fun ConversationNavigation3Entry( + route: ConversationRoute, + runtime: WireNavigation3Runtime, + actions: ConversationNavigation3Actions, +) { + val viewModelArgs = remember(route) { route.toViewModelArgs() } + var groupRequestId by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + var imagesRequestId by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + var galleryRequestId by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + var drawingRequestId by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + // Callbacks are intentionally not persisted. ConversationScreen re-registers them during every + // composition, before this entry's LaunchedEffect consumes a restored request. + val resultHandlers = remember { ConversationRouteResultHandlers() } + + val currentEntryId = runtime.navigator.currentRoute?.entryId + LaunchedEffect(currentEntryId, groupRequestId, imagesRequestId, galleryRequestId, drawingRequestId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + groupRequestId?.let(::WireNavResultRequestId)?.let { requestId -> + runtime.consumeResult(requestId, ConversationCompletionNavigation3ResultType)?.let { result -> + resultHandlers.groupDetails((result as? WireNavResult.Value)?.value?.toLegacy()) + groupRequestId = null + } + } + imagesRequestId?.let(::WireNavResultRequestId)?.let { requestId -> + runtime.consumeResult(requestId, ImagesPreviewNavigation3ResultType)?.let { result -> + resultHandlers.imagesPreview((result as? WireNavResult.Value)?.value?.toLegacy()) + imagesRequestId = null + } + } + galleryRequestId?.let(::WireNavResultRequestId)?.let { requestId -> + runtime.consumeResult(requestId, MediaGalleryNavigation3ResultType)?.let { result -> + resultHandlers.mediaGallery((result as? WireNavResult.Value)?.value?.toLegacy()) + galleryRequestId = null + } + } + drawingRequestId?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, DrawingCanvasNavigation3ResultType)) { + is WireNavResult.Value -> { + resultHandlers.drawingCanvas(result.value.uri?.let(Uri::parse)) + drawingRequestId = null + } + WireNavResult.Canceled -> { + resultHandlers.drawingCanvas(null) + drawingRequestId = null + } + null -> Unit + } + } + } + + val navigation = object : ConversationRouteScreenNavigation { + override fun goBack() { + if (!runtime.navigator.goBack()) actions.exitConversation() + } + + override fun replaceConversation(conversationId: ConversationId) { + runtime.navigator.navigate( + WireNavigationCommand( + destination = ConversationRoute( + sessionId = route.sessionId, + conversationId = conversationId.toConversationRouteId(), + ), + backStackMode = WireBackStackMode.REMOVE_CURRENT, + ) + ) + } + + override fun openSelfUserProfile() { + runtime.navigator.navigate(WireNavigationCommand(SelfUserProfileRoute(route.sessionId))) + } + + override fun openOtherUserProfile(userId: UserId, conversationId: ConversationId?) { + runtime.navigator.navigate( + WireNavigationCommand( + OtherUserProfileRoute( + sessionId = route.sessionId, + targetUserId = userId.toUserProfileId(), + groupConversationId = conversationId?.toUserProfileId(), + ) + ) + ) + } + + override fun openServiceDetails(args: ServiceDetailsNavArgs) { + runtime.navigator.navigate( + WireNavigationCommand(args.toServiceDetailsRoute(route.sessionId)) + ) + } + + override fun openMessageDetails( + conversationId: ConversationId, + messageId: String, + isSelfMessage: Boolean, + ) { + runtime.navigator.navigate( + WireNavigationCommand( + MessageDetailsRoute( + route.sessionId, + conversationId.toMediaId(), + messageId, + isSelfMessage, + ) + ) + ) + } + + override fun openGroupDetails(conversationId: ConversationId) { + groupRequestId = runtime.navigateForResult( + GroupConversationDetailsRoute( + route.sessionId, + conversationId.toDetailsId(), + ), + ConversationCompletionNavigation3ResultType, + )?.value + } + + override fun openImagesPreview( + conversationId: ConversationId, + conversationName: String, + assetUris: List, + ) { + imagesRequestId = runtime.navigateForResult( + ImagesPreviewRoute( + route.sessionId, + conversationId.toMediaId(), + conversationName, + assetUris.map { it.toString() }, + ), + ImagesPreviewNavigation3ResultType, + )?.value + } + + override fun openMediaGallery( + conversationId: ConversationId, + messageId: String, + isSelfAsset: Boolean, + isEphemeral: Boolean, + messageOptionsEnabled: Boolean, + cellAssetId: String?, + ) { + galleryRequestId = runtime.navigateForResult( + MediaGalleryRoute( + route.sessionId, + conversationId.toMediaId(), + messageId, + isSelfAsset, + isEphemeral, + messageOptionsEnabled, + cellAssetId, + ), + MediaGalleryNavigation3ResultType, + )?.value + } + + override fun openVideoPlayer(localPath: String?, contentUrl: String?, fileName: String?) { + runtime.navigator.navigate( + WireNavigationCommand( + VideoPlayerRoute(route.sessionId, localPath, contentUrl, fileName) + ) + ) + } + + override fun openDrawingCanvas(conversationName: String, tempWritableUri: Uri?) { + drawingRequestId = runtime.navigateForResult( + DrawingCanvasRoute( + route.sessionId, + conversationName, + tempWritableUri?.toString(), + ), + DrawingCanvasNavigation3ResultType, + )?.value + } + + override fun onGroupDetailsResult(handler: (GroupConversationDetailsNavBackArgs?) -> Unit) { + resultHandlers.groupDetails = handler + } + + override fun onImagesPreviewResult(handler: (ImagesPreviewNavBackArgs?) -> Unit) { + resultHandlers.imagesPreview = handler + } + + override fun onMediaGalleryResult(handler: (MediaGalleryNavBackArgs?) -> Unit) { + resultHandlers.mediaGallery = handler + } + + override fun onDrawingCanvasResult(handler: (Uri?) -> Unit) { + resultHandlers.drawingCanvas = handler + } + + override fun completeConversation(result: GroupConversationDetailsNavBackArgs) { + val typed = result.toNavigation3() + if (!runtime.completeCurrentAndPop( + ConversationCompletionNavigation3ResultType, + WireNavResult.Value(typed), + ) + ) { + actions.completeConversation(typed) + } + } + } + + ConversationRouteScreen( + navigation = navigation, + onShareAssetViaWire = { uri -> + runtime.navigator.navigate( + WireNavigationCommand( + AuthenticatedImportMediaRoute(route.sessionId, listOf(uri.toString())) + ) + ) + }, + conversationInfoViewModel = conversationInfoViewModel(viewModelArgs), + conversationBannerViewModel = conversationBannerViewModel(viewModelArgs), + conversationCallViewModel = conversationCallViewModel(viewModelArgs), + conversationMessagesViewModel = conversationMessagesViewModel(viewModelArgs), + messageComposerViewModel = messageComposerViewModel(viewModelArgs), + sendMessageViewModel = sendMessageViewModel(viewModelArgs), + conversationMigrationViewModel = conversationMigrationViewModel(viewModelArgs), + messageDraftViewModel = messageDraftViewModel(viewModelArgs), + messageAttachmentsViewModel = messageAttachmentsViewModel(viewModelArgs), + ) +} + +private fun ConversationId.toMediaId() = MediaConversationId(value, domain) +private fun ConversationId.toDetailsId() = ConversationDetailsId(value, domain) +private fun UserId.toUserProfileId() = UserProfileQualifiedId(value, domain) + +private fun ConversationCompletionResult.toLegacy() = GroupConversationDetailsNavBackArgs( + groupConversationActionType = when (action) { + ConversationCompletionAction.LEAVE_GROUP -> GroupConversationActionType.LEAVE_GROUP + ConversationCompletionAction.DELETE_GROUP -> GroupConversationActionType.DELETE_GROUP + }, + conversationName = conversationName, +) + +internal fun GroupConversationDetailsNavBackArgs.toNavigation3() = ConversationCompletionResult( + action = when (groupConversationActionType) { + GroupConversationActionType.LEAVE_GROUP -> ConversationCompletionAction.LEAVE_GROUP + GroupConversationActionType.DELETE_GROUP -> ConversationCompletionAction.DELETE_GROUP + }, + conversationName = conversationName, +) + +private fun ImagesPreviewResult.toLegacy() = + ImagesPreviewNavBackArgs(assets.map { it.toLegacy() }) + +private fun MediaGalleryResult.toLegacy() = MediaGalleryNavBackArgs( + messageId = messageId, + emoji = emoji, + isSelfAsset = isSelfAsset, + mediaGalleryActionType = when (action) { + MediaGalleryResultAction.REPLY -> MediaGalleryActionType.REPLY + MediaGalleryResultAction.REACT -> MediaGalleryActionType.REACT + MediaGalleryResultAction.DETAIL -> MediaGalleryActionType.DETAIL + }, + cellAssetId = cellAssetId, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Mappers.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Mappers.kt new file mode 100644 index 00000000000..58dbd7d8c3a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Mappers.kt @@ -0,0 +1,43 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.ui.home.conversations + +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.kalium.logic.data.asset.AttachmentType +import com.wire.kalium.logic.data.id.QualifiedID +import okio.Path.Companion.toPath + +internal fun ConversationRoute.toViewModelArgs() = ConversationNavArgs( + conversationId = conversationId.toQualifiedId(), + searchedMessageId = searchedMessageId, + pendingBundles = pendingAssets + .mapTo(ArrayList()) { it.toLegacy() } + .takeUnless { it.isEmpty() }, + pendingTextBundle = pendingText, +) + +internal fun ConversationRouteId.toQualifiedId() = QualifiedID(value, domain) +internal fun QualifiedID.toConversationRouteId() = ConversationRouteId(value, domain) + +internal fun AssetBundle.toConversationPendingAsset() = ConversationPendingAsset( + key = key, + mimeType = mimeType, + dataPath = dataPath.toString(), + dataSize = dataSize, + fileName = fileName, + assetType = assetType.name, + audioWavesMask = audioWavesMask, +) + +internal fun ConversationPendingAsset.toLegacy() = AssetBundle( + key = key, + mimeType = mimeType, + dataPath = dataPath.toPath(), + dataSize = dataSize, + fileName = fileName, + assetType = AttachmentType.valueOf(assetType), + audioWavesMask = audioWavesMask, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigationResults.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigationResults.kt deleted file mode 100644 index 1608c996ed2..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigationResults.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ -package com.wire.android.ui.home.conversations - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import com.ramcosta.composedestinations.generated.app.destinations.GroupConversationDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImagesPreviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MessageDetailsScreenDestination -import com.ramcosta.composedestinations.result.NavResult.Canceled -import com.ramcosta.composedestinations.result.NavResult.Value -import com.ramcosta.composedestinations.result.OpenResultRecipient -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.result.ResultRecipient -import com.wire.android.appLogger -import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs -import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs -import com.wire.android.ui.home.conversations.model.UIMessage -import com.wire.android.ui.home.conversations.model.UriAsset -import com.wire.android.ui.home.gallery.MediaGalleryActionType -import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs -import com.wire.android.ui.home.messagecomposer.model.ComposableMessageBundle -import com.wire.android.ui.home.messagecomposer.model.MessageBundle -import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder -import com.wire.kalium.logic.data.id.ConversationId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds - -@Suppress("CyclomaticComplexMethod", "LongParameterList") -@Composable -internal fun ConversationNavigationResults( - groupDetailsScreenResultRecipient: - ResultRecipient, - mediaGalleryScreenResultRecipient: ResultRecipient, - imagePreviewScreenResultRecipient: ResultRecipient, - drawingCanvasScreenResultRecipient: OpenResultRecipient, - resultNavigator: ResultBackNavigator, - navigator: Navigator, - conversationId: ConversationId, - messageComposerStateHolder: MessageComposerStateHolder, - getAndResetLastFullscreenMessage: (String) -> UIMessage.Regular?, - toggleReaction: (messageId: String, emoji: String) -> Unit, - trySendMessages: (List) -> Unit, - trySendMessage: (MessageBundle) -> Unit, - onConversationDeleted: () -> Unit, -) { - val coroutineScope = rememberCoroutineScope() - - groupDetailsScreenResultRecipient.onNavResult { result -> - when (result) { - is Canceled -> { - appLogger.i("Error with receiving navigation back args from groupDetails in ConversationScreen") - } - - is Value -> { - resultNavigator.setResult(result.value) - resultNavigator.navigateBack() - onConversationDeleted() - } - } - } - - mediaGalleryScreenResultRecipient.onNavResult { result -> - when (result) { - is Canceled -> { - appLogger.i("Error with receiving navigation back args from mediaGallery in ConversationScreen") - } - - is Value -> { - when (result.value.mediaGalleryActionType) { - MediaGalleryActionType.REPLY -> { - getAndResetLastFullscreenMessage(result.value.messageId)?.let { - coroutineScope.launch { - withSmoothScreenLoad { - messageComposerStateHolder.toReply(it) - } - } - } - } - - MediaGalleryActionType.REACT -> { - result.value.emoji?.let { toggleReaction(result.value.messageId, it) } - } - - MediaGalleryActionType.DETAIL -> { - getAndResetLastFullscreenMessage(result.value.messageId)?.let { - navigator.navigate( - NavigationCommand( - MessageDetailsScreenDestination( - conversationId, - result.value.messageId, - result.value.isSelfAsset, - ) - ) - ) - } - } - } - } - } - } - - imagePreviewScreenResultRecipient.onNavResult { result -> - when (result) { - Canceled -> Unit - is Value -> { - trySendMessages( - result.value.pendingBundles.map { assetBundle -> - ComposableMessageBundle.AttachmentPickedBundle( - conversationId = conversationId, - assetBundle = assetBundle, - ) - } - ) - } - } - } - - drawingCanvasScreenResultRecipient.onNavResult { result -> - when (result) { - Canceled -> Unit - is Value -> { - trySendMessage( - ComposableMessageBundle.UriPickedBundle( - conversationId = conversationId, - attachmentUri = UriAsset(result.value.uri), - ) - ) - } - } - } -} - -private fun CoroutineScope.withSmoothScreenLoad(block: () -> Unit) = launch { - val smoothAnimationDuration = 200.milliseconds - delay(smoothAnimationDuration) // we wait a bit until the whole screen is loaded to show the animation properly - block() -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt new file mode 100644 index 00000000000..5a429b66d70 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt @@ -0,0 +1,101 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.ui.home.conversations + +import android.net.Uri +import androidx.compose.runtime.Composable +import com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel +import com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel +import com.wire.android.ui.home.conversations.call.ConversationCallViewModel +import com.wire.android.ui.home.conversations.composer.MessageComposerViewModel +import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs +import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel +import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs +import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel +import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel +import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel +import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel +import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs +import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId + +@Suppress("TooManyFunctions") +internal interface ConversationRouteScreenNavigation { + fun goBack() + fun replaceConversation(conversationId: ConversationId) + fun openSelfUserProfile() + fun openOtherUserProfile(userId: UserId, conversationId: ConversationId? = null) + fun openServiceDetails(args: ServiceDetailsNavArgs) + fun openMessageDetails(conversationId: ConversationId, messageId: String, isSelfMessage: Boolean) + fun openGroupDetails( + conversationId: ConversationId, + ) + fun openImagesPreview( + conversationId: ConversationId, + conversationName: String, + assetUris: List, + ) + + @Suppress("LongParameterList") + fun openMediaGallery( + conversationId: ConversationId, + messageId: String, + isSelfAsset: Boolean, + isEphemeral: Boolean, + messageOptionsEnabled: Boolean, + cellAssetId: String?, + ) + + fun openVideoPlayer(localPath: String?, contentUrl: String?, fileName: String?) + + fun openDrawingCanvas( + conversationName: String, + tempWritableUri: Uri?, + ) + fun onGroupDetailsResult(handler: (GroupConversationDetailsNavBackArgs?) -> Unit) + fun onImagesPreviewResult(handler: (ImagesPreviewNavBackArgs?) -> Unit) + fun onMediaGalleryResult(handler: (MediaGalleryNavBackArgs?) -> Unit) + fun onDrawingCanvasResult(handler: (Uri?) -> Unit) + fun completeConversation(result: GroupConversationDetailsNavBackArgs) +} + +internal class ConversationRouteResultHandlers { + var groupDetails: (GroupConversationDetailsNavBackArgs?) -> Unit = {} + var imagesPreview: (ImagesPreviewNavBackArgs?) -> Unit = {} + var mediaGallery: (MediaGalleryNavBackArgs?) -> Unit = {} + var drawingCanvas: (Uri?) -> Unit = {} +} + +@Suppress("ComposeViewModelForwarding") +@Composable +internal fun ConversationRouteScreen( + navigation: ConversationRouteScreenNavigation, + onShareAssetViaWire: (Uri) -> Unit, + conversationInfoViewModel: ConversationInfoViewModel, + conversationBannerViewModel: ConversationBannerViewModel, + conversationCallViewModel: ConversationCallViewModel, + conversationMessagesViewModel: ConversationMessagesViewModel, + messageComposerViewModel: MessageComposerViewModel, + sendMessageViewModel: SendMessageViewModel, + conversationMigrationViewModel: ConversationMigrationViewModel, + messageDraftViewModel: MessageDraftViewModel, + messageAttachmentsViewModel: MessageAttachmentsViewModel, +) { + ConversationScreenRouteContent( + navigation = navigation, + onShareAssetViaWire = onShareAssetViaWire, + conversationInfoViewModel = conversationInfoViewModel, + conversationBannerViewModel = conversationBannerViewModel, + conversationCallViewModel = conversationCallViewModel, + conversationMessagesViewModel = conversationMessagesViewModel, + messageComposerViewModel = messageComposerViewModel, + sendMessageViewModel = sendMessageViewModel, + conversationMigrationViewModel = conversationMigrationViewModel, + messageDraftViewModel = messageDraftViewModel, + messageAttachmentsViewModel = messageAttachmentsViewModel, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index f655af52e17..de7ba41a68a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -42,36 +42,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.GroupConversationDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImagesPreviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MessageDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.VideoPlayerScreenDestination -import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination -import com.ramcosta.composedestinations.result.OpenResultRecipient -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.BuildConfig.IS_BUBBLE_UI_ENABLED import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs -import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.model.SnackBarMessage -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination import com.wire.android.ui.calling.conversationCallViewModel import com.wire.android.ui.common.attachmentdraft.model.AttachmentDraftUi import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState @@ -92,12 +73,10 @@ import com.wire.android.ui.home.conversations.call.HandleActions import com.wire.android.ui.home.conversations.call.HandleJoinOrStartCallScreenDialogs import com.wire.android.ui.home.conversations.composer.MessageComposerViewModel import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState -import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs import com.wire.android.ui.home.conversations.edit.MessageOptionsModalSheetLayout import com.wire.android.ui.home.conversations.info.ConversationDetailsData import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.info.ConversationInfoViewState -import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel @@ -108,7 +87,7 @@ import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionOptionsModalSheetLayout import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel -import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs +import com.wire.android.ui.home.gallery.MediaGalleryActionType import com.wire.android.ui.home.messagecomposer.location.LocationPickerComponent import com.wire.android.ui.home.messagecomposer.model.ComposableMessageBundle import com.wire.android.ui.home.messagecomposer.model.MessageBundle @@ -123,21 +102,23 @@ import com.wire.android.util.normalizeLink import com.wire.android.util.openDownloadFolder import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText -import com.wire.android.ui.sharing.ImportMediaNavArgs -import com.wire.android.ui.sharing.ImportSource import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.data.user.type.UserTypeInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import kotlinx.datetime.Instant +import kotlin.time.Duration.Companion.milliseconds import com.wire.android.ui.common.R as commonR /** @@ -145,30 +126,22 @@ import com.wire.android.ui.common.R as commonR */ private const val MAX_GROUP_SIZE_FOR_PING = 3 -// TODO: !! this screen definitely needs a refactor and some cleanup !! -@Suppress("ComplexMethod") -@WireRootDestination( - navArgs = ConversationNavArgs::class -) @Composable -fun ConversationScreen( - navigator: Navigator, - groupDetailsScreenResultRecipient: - ResultRecipient, - mediaGalleryScreenResultRecipient: ResultRecipient, - imagePreviewScreenResultRecipient: ResultRecipient, - drawingCanvasScreenResultRecipient: OpenResultRecipient, - resultNavigator: ResultBackNavigator, - conversationInfoViewModel: ConversationInfoViewModel = conversationInfoViewModel(), - conversationBannerViewModel: ConversationBannerViewModel = conversationBannerViewModel(), - conversationCallViewModel: ConversationCallViewModel = conversationCallViewModel(), - conversationMessagesViewModel: ConversationMessagesViewModel = conversationMessagesViewModel(), - messageComposerViewModel: MessageComposerViewModel = messageComposerViewModel(), - sendMessageViewModel: SendMessageViewModel = sendMessageViewModel(), - conversationMigrationViewModel: ConversationMigrationViewModel = conversationMigrationViewModel(), - messageDraftViewModel: MessageDraftViewModel = messageDraftViewModel(), - messageAttachmentsViewModel: MessageAttachmentsViewModel = messageAttachmentsViewModel(), +@Suppress("CyclomaticComplexMethod") +internal fun ConversationScreenRouteContent( + navigation: ConversationRouteScreenNavigation, + onShareAssetViaWire: (Uri) -> Unit, + conversationInfoViewModel: ConversationInfoViewModel, + conversationBannerViewModel: ConversationBannerViewModel, + conversationCallViewModel: ConversationCallViewModel, + conversationMessagesViewModel: ConversationMessagesViewModel, + messageComposerViewModel: MessageComposerViewModel, + sendMessageViewModel: SendMessageViewModel, + conversationMigrationViewModel: ConversationMigrationViewModel, + messageDraftViewModel: MessageDraftViewModel, + messageAttachmentsViewModel: MessageAttachmentsViewModel, ) { + val coroutineScope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current val context = LocalContext.current val resources = context.resources @@ -209,6 +182,63 @@ fun ConversationScreen( // then ViewModel also detects it's removed and calls onNotFound which can execute navigateBack again and close the app var alreadyDeletedByUser by rememberSaveable { mutableStateOf(false) } + navigation.onGroupDetailsResult { result -> + if (result == null) { + appLogger.i("Error with receiving navigation back args from groupDetails in ConversationScreen") + } else { + navigation.completeConversation(result) + alreadyDeletedByUser = true + } + } + navigation.onImagesPreviewResult { result -> + result?.let { + sendMessageViewModel.trySendMessages( + it.pendingBundles.map { assetBundle -> + ComposableMessageBundle.AttachmentPickedBundle( + conversationId = conversationMessagesViewModel.conversationId, + assetBundle = assetBundle, + ) + } + ) + } + } + navigation.onMediaGalleryResult { result -> + when (result?.mediaGalleryActionType) { + MediaGalleryActionType.REPLY -> + conversationMessagesViewModel.getAndResetLastFullscreenMessage(result.messageId)?.let { + coroutineScope.launch { + withSmoothScreenLoad { messageComposerStateHolder.toReply(it) } + } + } + + MediaGalleryActionType.REACT -> + result.emoji?.let { conversationMessagesViewModel.toggleReaction(result.messageId, it) } + + MediaGalleryActionType.DETAIL -> + conversationMessagesViewModel.getAndResetLastFullscreenMessage(result.messageId)?.let { + navigation.openMessageDetails( + conversationMessagesViewModel.conversationId, + result.messageId, + result.isSelfAsset, + ) + } + + null -> appLogger.i( + "Error with receiving navigation back args from mediaGallery in ConversationScreen" + ) + } + } + navigation.onDrawingCanvasResult { uri -> + uri?.let { + sendMessageViewModel.trySendMessage( + ComposableMessageBundle.UriPickedBundle( + conversationId = conversationMessagesViewModel.conversationId, + attachmentUri = UriAsset(it), + ) + ) + } + } + LaunchedEffect(conversationScreenState.isAnySheetVisible) { with(messageComposerStateHolder) { if (conversationScreenState.isAnySheetVisible) { @@ -223,7 +253,7 @@ fun ConversationScreen( } } LaunchedEffect(conversationInfoViewModel.conversationInfoViewState.notFound) { - if (conversationInfoViewModel.conversationInfoViewState.notFound) navigator.navigateBack() + if (conversationInfoViewModel.conversationInfoViewState.notFound) navigation.goBack() } // set message composer input to edit mode when editMessage is not null from MessageDraft @@ -278,12 +308,7 @@ fun ConversationScreen( } conversationMigrationViewModel.migratedConversationId?.let { migratedConversationId -> - navigator.navigate( - NavigationCommand( - ConversationScreenDestination(migratedConversationId), - BackStackMode.REMOVE_CURRENT - ) - ) + navigation.replaceConversation(migratedConversationId) } when (showDialog.value) { @@ -322,42 +347,35 @@ fun ConversationScreen( attachments = messageAttachmentsViewModel.attachments, onOpenProfile = { senderId: MessageSenderId -> with(conversationInfoViewModel) { - val route = when (senderId) { - is MessageSenderId.Bot -> ServiceDetailsScreenDestination( - null, - ServiceDetailsNavArgs.Id.BotServiceId(senderId.botService) + when (senderId) { + is MessageSenderId.Bot -> navigation.openServiceDetails( + ServiceDetailsNavArgs(null, ServiceDetailsNavArgs.Id.BotServiceId(senderId.botService)) ) - is MessageSenderId.App -> ServiceDetailsScreenDestination( - null, - ServiceDetailsNavArgs.Id.AppId(senderId.appId) + is MessageSenderId.App -> navigation.openServiceDetails( + ServiceDetailsNavArgs(null, ServiceDetailsNavArgs.Id.AppId(senderId.appId)) ) is MessageSenderId.User -> { val (mentionUserId: UserId, isSelfUser: Boolean) = mentionedUserData(senderId.id.toString()) if (isSelfUser) { - SelfUserProfileScreenDestination + navigation.openSelfUserProfile() } else { (conversationInfoViewState.conversationDetailsData as? ConversationDetailsData.Group) ?.conversationId?.let { conversationId -> - OtherUserProfileScreenDestination( - mentionUserId, - conversationId - ) - } + navigation.openOtherUserProfile(mentionUserId, conversationId) + } } } } - - route?.let { - navigator.navigate(NavigationCommand(it)) - } } }, onMessageDetailsClick = { messageId: String, isSelfMessage: Boolean -> appLogger.i("[ConversationScreen][openMessageDetails] - isSelfMessage: $isSelfMessage") - navigator.navigate( - NavigationCommand(MessageDetailsScreenDestination(conversationInfoViewModel.conversationId, messageId, isSelfMessage)) + navigation.openMessageDetails( + conversationInfoViewModel.conversationId, + messageId, + isSelfMessage, ) }, onSendMessage = { @@ -376,14 +394,10 @@ fun ConversationScreen( messageAttachmentsViewModel.onFilesSelected(it) messageComposerStateHolder.messageCompositionInputStateHolder.showAttachments(false) } else { - navigator.navigate( - NavigationCommand( - ImagesPreviewScreenDestination( - conversationId = conversationInfoViewModel.conversationInfoViewState.conversationId, - conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(resources), - assetUriList = ArrayList(it) - ) - ) + navigation.openImagesPreview( + conversationId = conversationInfoViewModel.conversationInfoViewState.conversationId, + conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(resources), + assetUris = it, ) } }, @@ -412,32 +426,18 @@ fun ConversationScreen( onAssetItemClicked = conversationMessagesViewModel::openOrFetchAsset, onImageFullScreenMode = { message, isSelfMessage, cellAssetId -> with(conversationMessagesViewModel) { - navigator.navigate( - NavigationCommand( - MediaGalleryScreenDestination( - conversationId = conversationId, - messageId = message.header.messageId, - isSelfAsset = isSelfMessage, - isEphemeral = message.header.messageStatus.expirationStatus is ExpirationStatus.Expirable, - messageOptionsEnabled = true, - cellAssetId = cellAssetId, - ) - ) - ) updateImageOnFullscreenMode(message) - } - }, - onVideoClick = { localPath, contentUrl, fileName -> - navigator.navigate( - NavigationCommand( - VideoPlayerScreenDestination( - localPath = localPath, - contentUrl = contentUrl, - fileName = fileName, - ) + navigation.openMediaGallery( + conversationId = conversationId, + messageId = message.header.messageId, + isSelfAsset = isSelfMessage, + isEphemeral = message.header.messageStatus.expirationStatus is ExpirationStatus.Expirable, + messageOptionsEnabled = true, + cellAssetId = cellAssetId, ) - ) + } }, + onVideoClick = navigation::openVideoPlayer, onStartCall = { conversationCallViewModel.startCallIfPossible(conversationInfoViewModel.conversationInfoViewState.conversationType) }, @@ -449,59 +449,43 @@ fun ConversationScreen( onUpdateConversationReadDate = messageComposerViewModel::updateConversationReadDate, onDropDownClick = { with(conversationInfoViewModel) { - val route = when (val data = conversationInfoViewState.conversationDetailsData) { + when (val data = conversationInfoViewState.conversationDetailsData) { is ConversationDetailsData.OneOne -> { val botService = data.botService when { - botService != null -> - ServiceDetailsScreenDestination( + botService != null -> navigation.openServiceDetails( + ServiceDetailsNavArgs( null, - ServiceDetailsNavArgs.Id.BotServiceId(botService) + ServiceDetailsNavArgs.Id.BotServiceId(botService), ) + ) - data.userType == UserTypeInfo.App -> - ServiceDetailsScreenDestination( + data.userType == UserTypeInfo.App -> navigation.openServiceDetails( + ServiceDetailsNavArgs( null, - ServiceDetailsNavArgs.Id.AppId(data.otherUserId) + ServiceDetailsNavArgs.Id.AppId(data.otherUserId), ) + ) - else -> OtherUserProfileScreenDestination(data.otherUserId) + else -> navigation.openOtherUserProfile(data.otherUserId) } } - is ConversationDetailsData.Group -> - GroupConversationDetailsScreenDestination(conversationId) + is ConversationDetailsData.Group -> navigation.openGroupDetails(conversationId) - is ConversationDetailsData.None -> { - /* do nothing */ - null - } - } - - route?.let { - navigator.navigate(NavigationCommand(it)) + is ConversationDetailsData.None -> Unit } } }, onBackButtonClick = { - conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigator) + conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigation::goBack) }, composerMessages = sendMessageViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, - shareAssetExternally = conversationMessagesViewModel::shareAsset, + shareAsset = conversationMessagesViewModel::shareAsset, shareAssetViaWire = { messageId -> conversationMessagesViewModel.prepareAssetForWireShare(messageId) { path, assetName -> - navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination( - ImportMediaNavArgs( - source = ImportSource.INTERNAL_SHARE, - internalAssetUriList = arrayListOf(context.fileShareUri(path, assetName)) - ) - ), - BackStackMode.UPDATE_EXISTED - ) - ) + onShareAssetViaWire(context.fileShareUri(path, assetName)) } }, onDownloadAssetClick = conversationMessagesViewModel::openOrFetchAsset, @@ -545,15 +529,9 @@ fun ConversationScreen( } }, openDrawingCanvas = { - navigator.navigate( - NavigationCommand( - DrawingCanvasScreenDestination( - DrawingCanvasNavArgs( - conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(resources), - tempWritableUri = messageComposerViewModel.tempWritableImageUri - ) - ) - ) + navigation.openDrawingCanvas( + conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(resources), + tempWritableUri = messageComposerViewModel.tempWritableImageUri, ) }, currentTimeInMillisFlow = conversationMessagesViewModel.currentTimeInMillisFlow, @@ -566,7 +544,9 @@ fun ConversationScreen( hasMoreRemoteMessages = conversationMessagesViewModel.conversationViewState.hasMoreRemoteMessages, isWireCellsEnabled = conversationInfoViewModel.conversationInfoViewState.isWireCellEnabled, ) - BackHandler { conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigator) } + BackHandler { + conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigation::goBack) + } // Mark conversation as read when leaving, regardless of how the user exits // (back button, system gesture, navigation to another screen, etc.) @@ -614,22 +594,12 @@ fun ConversationScreen( dismissIncompatibleFileName = messageAttachmentsViewModel::onDismissIncompatibleFileNameDialog, ), ) +} - ConversationNavigationResults( - groupDetailsScreenResultRecipient = groupDetailsScreenResultRecipient, - mediaGalleryScreenResultRecipient = mediaGalleryScreenResultRecipient, - imagePreviewScreenResultRecipient = imagePreviewScreenResultRecipient, - drawingCanvasScreenResultRecipient = drawingCanvasScreenResultRecipient, - resultNavigator = resultNavigator, - navigator = navigator, - conversationId = conversationMessagesViewModel.conversationId, - messageComposerStateHolder = messageComposerStateHolder, - getAndResetLastFullscreenMessage = conversationMessagesViewModel::getAndResetLastFullscreenMessage, - toggleReaction = conversationMessagesViewModel::toggleReaction, - trySendMessages = sendMessageViewModel::trySendMessages, - trySendMessage = sendMessageViewModel::trySendMessage, - onConversationDeleted = { alreadyDeletedByUser = true }, - ) +private fun CoroutineScope.withSmoothScreenLoad(block: () -> Unit) = launch { + val smoothAnimationDuration = 200.milliseconds + delay(smoothAnimationDuration) + block() } private fun MessageBundle.withPrefetchedLinkPreview( @@ -644,11 +614,11 @@ private fun MessageBundle.withPrefetchedLinkPreview( private fun conversationScreenOnBackButtonClick( messageComposerViewModel: MessageComposerViewModel, messageComposerStateHolder: MessageComposerStateHolder, - navigator: Navigator + navigateBack: () -> Unit, ) { messageComposerViewModel.sendTypingEvent(TypingIndicatorMode.STOPPED) messageComposerStateHolder.messageCompositionInputStateHolder.collapseComposer(null) - navigator.navigateBack() + navigateBack() } @Suppress("LongParameterList") @@ -681,7 +651,7 @@ private fun ConversationScreenContent( onBackButtonClick: () -> Unit, composerMessages: SharedFlow, conversationMessages: SharedFlow, - shareAssetExternally: (Context, messageId: String) -> Unit, + shareAsset: (Context, messageId: String) -> Unit, shareAssetViaWire: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, onOpenAssetClick: (messageId: String) -> Unit, @@ -826,7 +796,7 @@ private fun ConversationScreenContent( onDetailsClick = onMessageDetailsClick, onReplyClick = messageComposerStateHolder::toReply, onEditClick = messageComposerStateHolder::toEdit, - onShareAssetExternallyClick = { shareAssetExternally(context, it) }, + onShareAssetExternallyClick = { shareAsset(context, it) }, onShareAssetViaWireClick = shareAssetViaWire, onDownloadAssetClick = onDownloadAssetClick, onOpenAssetClick = onOpenAssetClick, @@ -935,7 +905,7 @@ fun PreviewConversationScreen() = WireTheme { onBackButtonClick = {}, composerMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), conversationMessages = MutableStateFlow(ConversationSnackbarMessages.ErrorDownloadingAsset), - shareAssetExternally = { _, _ -> }, + shareAsset = { _, _ -> }, shareAssetViaWire = {}, onOpenAssetClick = {}, onDownloadAssetClick = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderMetroViewModelBindings.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderMetroViewModelBindings.kt new file mode 100644 index 00000000000..051be54028e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderMetroViewModelBindings.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.home.conversations + +import androidx.lifecycle.ViewModel +import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs +import com.wire.android.ui.home.conversations.folder.ConversationFoldersVMImpl +import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderArgs +import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderVMImpl +import com.wire.android.ui.home.conversations.folder.NewFolderViewModel +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel +import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs +import com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesNavArgs +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +@BindingContainer +object ConversationSearchFolderMetroViewModelBindings { + + @Provides + @IntoMap + @ViewModelKey(NewFolderViewModel::class) + fun newFolderViewModel(viewModel: NewFolderViewModel): ViewModel = viewModel + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(ConversationSearchFolderManualViewModelFactory::class) + internal fun manualFactory( + foldersFactory: ConversationFoldersVMImpl.Factory, + moveFactory: MoveConversationToFolderVMImpl.Factory, + addMembersFactory: AddMembersToConversationViewModel.Factory, + searchMessagesFactory: SearchConversationMessagesViewModel.Factory, + promoteAdminFactory: PromoteAdminViewModel.Factory, + ): ManualViewModelAssistedFactory = object : ConversationSearchFolderManualViewModelFactory { + override fun conversationFoldersViewModel(args: ConversationFoldersStateArgs) = foldersFactory.create(args) + override fun moveConversationToFolderViewModel(args: MoveConversationToFolderArgs) = moveFactory.create(args) + override fun addMembersToConversationViewModel(args: AddMembersSearchNavArgs) = addMembersFactory.create(args) + override fun searchConversationMessagesViewModel(args: SearchConversationMessagesNavArgs) = + searchMessagesFactory.create(args) + override fun promoteAdminViewModel(args: PromoteAdminNavArgs) = promoteAdminFactory.create(args) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderViewModelGraph.kt index 18e67bbdb6d..2ca8a88eaee 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationSearchFolderViewModelGraph.kt @@ -21,8 +21,9 @@ package com.wire.android.ui.home.conversations import androidx.compose.runtime.Composable import com.wire.android.di.ViewModelScopedPreviews -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModelAs +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs import com.wire.android.ui.home.conversations.folder.ConversationFoldersVM import com.wire.android.ui.home.conversations.folder.ConversationFoldersVMImpl @@ -31,27 +32,33 @@ import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderVM import com.wire.android.ui.home.conversations.folder.MoveConversationToFolderVMImpl import com.wire.android.ui.home.conversations.folder.NewFolderViewModel import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs +import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs import com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesNavArgs import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface ConversationSearchFolderManualViewModelFactory : ManualViewModelAssistedFactory { fun conversationFoldersViewModel(args: ConversationFoldersStateArgs): ConversationFoldersVMImpl fun moveConversationToFolderViewModel(args: MoveConversationToFolderArgs): MoveConversationToFolderVMImpl + fun addMembersToConversationViewModel(args: AddMembersSearchNavArgs): AddMembersToConversationViewModel + fun searchConversationMessagesViewModel(args: SearchConversationMessagesNavArgs): SearchConversationMessagesViewModel + fun promoteAdminViewModel(args: PromoteAdminNavArgs): PromoteAdminViewModel } @Composable fun conversationFoldersViewModel( args: ConversationFoldersStateArgs ): ConversationFoldersVM = - sessionKeyedAssistedMetroViewModelAs< + wireAssistedMetroViewModelAs< ConversationFoldersVMImpl, ConversationFoldersVM, ConversationSearchFolderManualViewModelFactory, >( - key = "conversation_folders_${args.selectedFolderId}", + instanceKey = "conversation_folders_${args.selectedFolderId}", previewProvider = ViewModelScopedPreviews, - ) { + ) { _ -> conversationFoldersViewModel(args) } @@ -59,29 +66,38 @@ fun conversationFoldersViewModel( fun moveConversationToFolderViewModel( args: MoveConversationToFolderArgs ): MoveConversationToFolderVM = - sessionKeyedAssistedMetroViewModelAs< + wireAssistedMetroViewModelAs< MoveConversationToFolderVMImpl, MoveConversationToFolderVM, ConversationSearchFolderManualViewModelFactory, >( - key = "move_conversation_to_folder_${args.conversationId}_${args.currentFolderId}", + instanceKey = "move_conversation_to_folder_${args.conversationId}_${args.currentFolderId}", previewProvider = ViewModelScopedPreviews, - ) { + ) { _ -> moveConversationToFolderViewModel(args) } @Composable fun newFolderViewModel(): NewFolderViewModel = - sessionKeyedMetroViewModel() + wireMetroViewModel() @Composable -fun addMembersToConversationViewModel(): AddMembersToConversationViewModel = - sessionKeyedMetroViewModel() +fun addMembersToConversationViewModel(args: AddMembersSearchNavArgs): AddMembersToConversationViewModel = + conversationSearchFolderAssistedViewModel { addMembersToConversationViewModel(args) } @Composable -fun searchConversationMessagesViewModel(): SearchConversationMessagesViewModel = - sessionKeyedMetroViewModel() +fun searchConversationMessagesViewModel(args: SearchConversationMessagesNavArgs): SearchConversationMessagesViewModel = + conversationSearchFolderAssistedViewModel { searchConversationMessagesViewModel(args) } @Composable -fun promoteAdminViewModel(): PromoteAdminViewModel = - sessionKeyedMetroViewModel() +fun promoteAdminViewModel(args: PromoteAdminNavArgs): PromoteAdminViewModel = + conversationSearchFolderAssistedViewModel { promoteAdminViewModel(args) } + +@Composable +private inline fun conversationSearchFolderAssistedViewModel( + crossinline create: ConversationSearchFolderManualViewModelFactory.() -> VM, +): VM where VM : androidx.lifecycle.ViewModel = + wireAssistedMetroViewModel< + VM, + ConversationSearchFolderManualViewModelFactory, + >(create = { _ -> create() }) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ScopedMessageViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ScopedMessageViewModelGraph.kt index be83178b1ab..f459e64aae7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ScopedMessageViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ScopedMessageViewModelGraph.kt @@ -20,11 +20,12 @@ package com.wire.android.ui.home.conversations import androidx.compose.runtime.Composable -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.sebaslogen.resaca.KeyInScopeResolver import com.wire.android.di.ScopedArgs import com.wire.android.di.ViewModelScopedPreviews +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModelAs import com.wire.android.di.wireManualMetroViewModelScoped import com.wire.android.media.audiomessage.AudioMessageArgs import com.wire.android.media.audiomessage.AudioMessageViewModel @@ -49,7 +50,7 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import kotlin.time.Duration internal interface ScopedMessageManualViewModelFactory : ManualViewModelAssistedFactory { - fun compositeMessageViewModel(savedStateHandle: SavedStateHandle, args: CompositeMessageArgs): CompositeMessageViewModelImpl + fun compositeMessageViewModel(args: CompositeMessageArgs): CompositeMessageViewModelImpl fun messageOptionsMenuViewModel(args: MessageOptionsMenuArgs): MessageOptionsMenuViewModelImpl fun typingIndicatorViewModel(args: TypingIndicatorArgs): TypingIndicatorViewModelImpl fun assetLocalPathViewModel(args: AssetLocalPathArgs): AssetLocalPathViewModelImpl @@ -64,14 +65,14 @@ internal interface ScopedMessageManualViewModelFactory : ManualViewModelAssisted private inline fun scopedMessageViewModel( arguments: R, clearDelay: Duration? = null, - noinline create: ScopedMessageManualViewModelFactory.(SavedStateHandle, R) -> VM, + noinline create: ScopedMessageManualViewModelFactory.(R) -> VM, ): S where VM : ViewModel, VM : S = wireManualMetroViewModelScoped( arguments = arguments, previewProvider = ViewModelScopedPreviews, clearDelay = clearDelay, - ) { savedStateHandle, scopedArgs -> - create(savedStateHandle, scopedArgs) + ) { scopedArgs -> + create(scopedArgs) } @Composable @@ -80,28 +81,15 @@ private inline fun scopedMessage arguments: R, noinline keyInScopeResolver: KeyInScopeResolver, clearDelay: Duration? = null, - noinline create: ScopedMessageManualViewModelFactory.(SavedStateHandle, R) -> VM, + noinline create: ScopedMessageManualViewModelFactory.(R) -> VM, ): S where VM : ViewModel, VM : S = wireManualMetroViewModelScoped( arguments = arguments, previewProvider = ViewModelScopedPreviews, keyInScopeResolver = keyInScopeResolver, clearDelay = clearDelay, - ) { savedStateHandle, scopedArgs -> - create(savedStateHandle, scopedArgs) - } - -@Composable -@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") -private inline fun scopedMessageViewModel( - clearDelay: Duration? = null, - noinline create: ScopedMessageManualViewModelFactory.(SavedStateHandle) -> VM, -): S where VM : ViewModel, VM : S = - wireManualMetroViewModelScoped( - previewProvider = ViewModelScopedPreviews, - clearDelay = clearDelay, - ) { savedStateHandle -> - create(savedStateHandle) + ) { scopedArgs -> + create(scopedArgs) } @Composable @@ -110,24 +98,37 @@ fun compositeMessageViewModel( ): CompositeMessageViewModel = scopedMessageViewModel( arguments = args, - ) { savedStateHandle, scopedArgs -> - compositeMessageViewModel(savedStateHandle, scopedArgs) + ) { scopedArgs -> + compositeMessageViewModel(scopedArgs) } @Composable fun messageOptionsMenuViewModel( args: MessageOptionsMenuArgs, ): MessageOptionsMenuViewModel = - scopedMessageViewModel(args) { _, scopedArgs -> - messageOptionsMenuViewModel(scopedArgs) + wireAssistedMetroViewModelAs< + MessageOptionsMenuViewModelImpl, + MessageOptionsMenuViewModel, + ScopedMessageManualViewModelFactory, + >( + previewProvider = ViewModelScopedPreviews, + ) { _ -> + messageOptionsMenuViewModel(args) } @Composable fun typingIndicatorViewModel( args: TypingIndicatorArgs, ): TypingIndicatorViewModel = - scopedMessageViewModel(args) { _, scopedArgs -> - typingIndicatorViewModel(scopedArgs) + wireAssistedMetroViewModelAs< + TypingIndicatorViewModelImpl, + TypingIndicatorViewModel, + ScopedMessageManualViewModelFactory, + >( + instanceKey = args.key, + previewProvider = ViewModelScopedPreviews, + ) { _ -> + typingIndicatorViewModel(args) } @Composable @@ -139,11 +140,11 @@ fun assetLocalPathViewModel( scopedMessageViewModel( arguments = args, keyInScopeResolver = keyInScopeResolver, - ) { _, scopedArgs -> + ) { scopedArgs -> assetLocalPathViewModel(scopedArgs) } } else { - scopedMessageViewModel(args) { _, scopedArgs -> + scopedMessageViewModel(args) { scopedArgs -> assetLocalPathViewModel(scopedArgs) } } @@ -152,25 +153,33 @@ fun assetLocalPathViewModel( fun selfDeletingMessageActionViewModel( args: SelfDeletingMessageActionArgs, ): SelfDeletingMessageActionViewModel = - scopedMessageViewModel< - SelfDeletingMessageActionViewModelImpl, - SelfDeletingMessageActionViewModel, - SelfDeletingMessageActionArgs, - >(args) { _, scopedArgs -> - selfDeletingMessageActionViewModel(scopedArgs) + wireAssistedMetroViewModelAs< + SelfDeletingMessageActionViewModelImpl, + SelfDeletingMessageActionViewModel, + ScopedMessageManualViewModelFactory, + >( + previewProvider = ViewModelScopedPreviews, + ) { _ -> + selfDeletingMessageActionViewModel(args) } @Composable fun isFileSharingEnabledViewModel(): IsFileSharingEnabledViewModel = - scopedMessageViewModel { _ -> + wireAssistedMetroViewModelAs< + IsFileSharingEnabledViewModelImpl, + IsFileSharingEnabledViewModel, + ScopedMessageManualViewModelFactory, + >( + previewProvider = ViewModelScopedPreviews, + ) { _ -> isFileSharingEnabledViewModel() } @Composable fun recordAudioViewModel(): RecordAudioViewModel = - wireManualMetroViewModelScoped( - previewProvider = ViewModelScopedPreviews - ) { + wireAssistedMetroViewModel( + previewProvider = ViewModelScopedPreviews, + ) { _ -> recordAudioViewModel() } @@ -183,11 +192,11 @@ fun audioMessageViewModel( scopedMessageViewModel( arguments = args, keyInScopeResolver = keyInScopeResolver, - ) { _, scopedArgs -> + ) { scopedArgs -> audioMessageViewModel(scopedArgs) } } else { - scopedMessageViewModel(args) { _, scopedArgs -> + scopedMessageViewModel(args) { scopedArgs -> audioMessageViewModel(scopedArgs) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt index 7c3041aaf36..06ec5b7b0c5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModel.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger @@ -34,7 +33,6 @@ import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.MessageSharedState import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.sharing.ImportedMediaAsset import com.wire.android.util.FileManager import com.wire.android.util.GetMediaMetadataUseCase @@ -67,7 +65,6 @@ import java.io.File @Suppress("TooManyFunctions", "LongParameterList") class MessageAttachmentsViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, private val handleUriAsset: HandleUriAssetUseCase, private val observeAttachments: ObserveAttachmentDraftsUseCase, private val addAttachment: AddAttachmentDraftUseCase, @@ -77,14 +74,15 @@ class MessageAttachmentsViewModel @AssistedInject constructor( private val fileManager: FileManager, private val sharedState: MessageSharedState, private val getMediaMetadata: GetMediaMetadataUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): MessageAttachmentsViewModel + fun create(navigationArgs: ConversationNavArgs): MessageAttachmentsViewModel } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs private val conversationId: QualifiedID = conversationNavArgs.conversationId private val uploadObservers = mutableMapOf() private val removedAttachments = MutableStateFlow(emptyList()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModel.kt index b36b29c2300..cc8c7af6cb7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModel.kt @@ -21,13 +21,11 @@ package com.wire.android.ui.home.conversations.banner import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.banner.usecase.ObserveConversationMembersByTypesUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.QualifiedID @@ -48,20 +46,20 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class ConversationBannerViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, private val observeConversationMembersByTypes: ObserveConversationMembersByTypesUseCase, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val notifyConversationIsOpen: NotifyConversationIsOpenUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ConversationBannerViewModel + fun create(navigationArgs: ConversationNavArgs): ConversationBannerViewModel } var bannerState by mutableStateOf(null) - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId init { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt index cddd36c6c4d..c087f1d18fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt @@ -21,10 +21,8 @@ package com.wire.android.ui.home.conversations.call import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.di.CurrentAccount import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase @@ -53,7 +51,6 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList", "TooManyFunctions") class ConversationCallViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, @CurrentAccount currentAccount: UserId, private val observeJoinableCalls: ObserveJoinableCallsUseCase, private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, @@ -66,13 +63,14 @@ class ConversationCallViewModel @AssistedInject constructor( private val setUserInformedAboutVerification: SetUserInformedAboutVerificationUseCase, private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase, private val observeConferenceCallingEnabled: ObserveConferenceCallingEnabledUseCase, - private val observeSelf: ObserveSelfUserUseCase + private val observeSelf: ObserveSelfUserUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { + @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ConversationCallViewModel + fun create(navigationArgs: ConversationNavArgs): ConversationCallViewModel } - val callManager = JoinOrStartCallManager( scope = viewModelScope, currentAccount = currentAccount, @@ -87,7 +85,7 @@ class ConversationCallViewModel @AssistedInject constructor( observeSelf = observeSelf, ) - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId var conversationCallViewState by mutableStateOf(ConversationCallViewState()) private set diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt index b7de961494e..dc7253051e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/channels/BrowseChannelsScreen.kt @@ -25,9 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType @@ -36,15 +33,12 @@ import com.wire.android.ui.common.topappbar.search.SearchTopBar import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination( - style = PopUpNavigationAnimation::class, -) @Composable -fun BrowseChannelsScreen(navigator: Navigator) { +internal fun BrowseChannelsRouteScreen(onCloseSearchClicked: () -> Unit) { Content( searchQueryTextState = TextFieldState(), // onChannelClick = { /*TODO when BE supports public search*/ }, - onCloseSearchClicked = navigator::navigateBack + onCloseSearchClicked = onCloseSearchClicked, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt index 439c419f159..0d9060a474b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt @@ -22,10 +22,8 @@ import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.datastore.GlobalDataStore import com.wire.android.mapper.ContactMapper import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -72,7 +70,6 @@ import kotlinx.datetime.Instant @Suppress("LongParameterList", "TooManyFunctions") class MessageComposerViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, private val dispatchers: DispatcherProvider, private val isFileSharingEnabled: IsFileSharingEnabledUseCase, private val observeConversationInteractionAvailability: ObserveConversationInteractionAvailabilityUseCase, @@ -89,11 +86,12 @@ class MessageComposerViewModel @AssistedInject constructor( private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, private val globalDataStore: GlobalDataStore, private val isSelfUserViewerOnConversation: IsSelfUserViewerOnConversationUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): MessageComposerViewModel + fun create(navigationArgs: ConversationNavArgs): MessageComposerViewModel } var messageComposerViewState = mutableStateOf(MessageComposerViewState()) @@ -105,7 +103,7 @@ class MessageComposerViewModel @AssistedInject constructor( var tempWritableImageUri: Uri? = null private set - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId var visitLinkDialogState: VisitLinkDialogState by mutableStateOf( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3.kt new file mode 100644 index 00000000000..f9e1703d177 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3.kt @@ -0,0 +1,173 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations.details + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationDetailsId(val value: String, val domain: String) { + init { + require(value.isNotBlank()) + require(domain.isNotBlank()) + } +} + +sealed interface ConversationDetailsRoute : SessionRoute { + val conversationId: ConversationDetailsId +} + +@Serializable +data class GroupConversationDetailsRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/group_conversation_details_screen" + } +} + +@Serializable +data class EditConversationNameRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/edit_conversation_name_screen" + } +} + +@Serializable +data class EditSelfDeletingMessagesRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/edit_self_deleting_messages_screen" + } +} + +@Serializable +data class GroupConversationAllParticipantsRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/group_conversation_all_participants_screen" + } +} + +@Serializable +data class UpdateAppsAccessRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + val isGuestAllowed: Boolean, + val isAppsAllowed: Boolean, + val shouldUseNewAppsUi: Boolean, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/update_apps_access_screen" + } +} + +@Serializable +enum class ChannelAccessSelection { + PUBLIC, + PRIVATE, +} + +@Serializable +enum class ChannelPermissionSelection { + ADMINS, + EVERYONE, +} + +@Serializable +data class ChannelAccessOnUpdateRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + val accessType: ChannelAccessSelection = ChannelAccessSelection.PRIVATE, + val permissionType: ChannelPermissionSelection = ChannelPermissionSelection.ADMINS, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/channel_access_on_update_screen" + } +} + +@Serializable +data class EditGuestAccessRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + val isGuestAccessAllowed: Boolean, + val isServicesAllowed: Boolean, + val isUpdatingGuestAccessAllowed: Boolean, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/edit_guest_access_screen" + } +} + +@Serializable +data class CreatePasswordProtectedGuestLinkRoute( + override val sessionId: WireSessionId, + override val conversationId: ConversationDetailsId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : ConversationDetailsRoute { + override val routeId = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/create_password_protected_guest_link_screen" + } +} + +@Serializable +data class EditConversationNameResult(val succeeded: Boolean) + +internal val EditConversationNameResultContract = + WireNavResultContract( + WireNavResultContractId("conversation-details.edit-name") + ) + +@Serializable +data class ChannelAccessUpdateResult( + val accessType: ChannelAccessSelection, + val permissionType: ChannelPermissionSelection, +) + +internal val ChannelAccessUpdateResultContract = + WireNavResultContract( + WireNavResultContractId("conversation-details.channel-access") + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Entries.kt new file mode 100644 index 00000000000..4ec2f01b93e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Entries.kt @@ -0,0 +1,316 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.ui.home.conversations.ConversationCompletionNavigation3ResultType +import com.wire.android.ui.home.conversations.ConversationAuxId +import com.wire.android.ui.home.conversations.ConversationFoldersNavigation3ResultType +import com.wire.android.ui.home.conversations.ConversationFoldersRoute +import com.wire.android.ui.home.conversations.toNavigation3 +import com.wire.android.ui.home.conversations.createPasswordGuestLinkViewModel +import com.wire.android.ui.home.conversations.editConversationMetadataViewModel +import com.wire.android.ui.home.conversations.editGuestAccessViewModel +import com.wire.android.ui.home.conversations.editSelfDeletingMessagesViewModel +import com.wire.android.ui.home.conversations.groupConversationDetailsViewModel +import com.wire.android.ui.home.conversations.groupConversationParticipantsViewModel +import com.wire.android.ui.home.conversations.updateAppsAccessViewModel +import com.wire.android.ui.home.conversations.updateChannelAccessViewModel +import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessRouteScreen +import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordProtectedGuestLinkRouteScreen +import com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesRouteScreen +import com.wire.android.ui.home.conversations.details.metadata.EditConversationNameRouteScreen +import com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsRouteScreen +import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessRouteScreen +import com.wire.android.ui.home.conversations.details.updatechannelaccess.ChannelAccessOnUpdateRouteScreen +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireNavigationCommand + +internal val EditConversationNameNavigation3ResultType = WireNavigation3ResultType( + EditConversationNameResultContract, + EditConversationNameResult.serializer(), +) + +internal val ChannelAccessUpdateNavigation3ResultType = WireNavigation3ResultType( + ChannelAccessUpdateResultContract, + ChannelAccessUpdateResult.serializer(), +) + +internal interface ConversationDetailsNavigation3Actions { + fun exitDetails() + fun openParticipantProfile(participant: UIParticipant, conversationId: ConversationDetailsId) + fun openAddMembers( + conversationId: ConversationDetailsId, + isConversationAppsEnabled: Boolean, + isSelfPartOfATeam: Boolean, + protocolInfo: Conversation.ProtocolInfo, + shouldUseNewAppsUi: Boolean, + ) + + fun openSearchMessages(conversationId: ConversationDetailsId, isCellsConversation: Boolean, groupName: String) + fun openConversationMedia(conversationId: ConversationDetailsId, isCellsConversation: Boolean, groupName: String) + fun completeDetails(action: GroupConversationActionType, conversationName: String) + fun openPromoteAdmin(conversationId: ConversationId, eligibleMembers: List) + fun openConversationDebugMenu(conversationId: ConversationId) +} + +internal object ConversationDetailsNavigation3Contribution { + val resultTypes: List> = listOf( + EditConversationNameNavigation3ResultType, + ChannelAccessUpdateNavigation3ResultType, + ) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: ConversationDetailsNavigation3Actions, + ): List = listOf( + conversationDetailsNavigation3Entries(runtime, actions) + ) +} + +@Suppress("LongMethod") +internal fun conversationDetailsNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: ConversationDetailsNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { + GroupConversationDetailsNavigation3Entry(it, runtime, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + EditConversationNameNavigation3Entry(it, runtime) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + EditSelfDeletingMessagesRouteScreen( + viewModel = editSelfDeletingMessagesViewModel(it.toViewModelArgs()), + onNavigateBack = runtime.navigator::goBack, + ) + } + wireEntry { + GroupConversationAllParticipantsRouteScreen( + viewModel = groupConversationParticipantsViewModel(it.toViewModelArgs()), + onBackPressed = runtime.navigator::goBack, + onProfilePressed = { participant -> + actions.openParticipantProfile(participant, it.conversationId) + }, + ) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + UpdateAppsAccessRouteScreen( + viewModel = updateAppsAccessViewModel(it.toViewModelArgs()), + onNavigateBack = runtime.navigator::goBack, + ) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + ChannelAccessOnUpdateRouteScreen( + viewModel = updateChannelAccessViewModel(it.toViewModelArgs()), + onNavigateBack = { result -> + val typedResult = ChannelAccessUpdateResult( + result.accessType.toNavigation3(), + result.permissionType.toNavigation3(), + ) + if (!runtime.completeCurrentAndPop( + ChannelAccessUpdateNavigation3ResultType, + WireNavResult.Value(typedResult), + ) + ) { + runtime.navigator.goBack() + } + }, + ) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + EditGuestAccessRouteScreen( + viewModel = editGuestAccessViewModel(it.toViewModelArgs()), + onNavigateBack = runtime.navigator::goBack, + onCreatePasswordProtectedLink = { + runtime.navigator.navigate( + WireNavigationCommand( + CreatePasswordProtectedGuestLinkRoute(it.sessionId, it.conversationId) + ) + ) + }, + ) + } + wireEntry { + CreatePasswordProtectedGuestLinkRouteScreen( + viewModel = createPasswordGuestLinkViewModel(it.toViewModelArgs()), + navigateBack = runtime.navigator::goBack, + ) + } +} + +@Composable +private fun EditConversationNameNavigation3Entry( + route: EditConversationNameRoute, + runtime: WireNavigation3Runtime, +) { + EditConversationNameRouteScreen( + viewModel = editConversationMetadataViewModel(route.toViewModelArgs()), + onNavigateBack = runtime.navigator::goBack, + onCompleted = { + if (!runtime.completeCurrentAndPop( + EditConversationNameNavigation3ResultType, + WireNavResult.Value(EditConversationNameResult(it)), + ) + ) { + runtime.navigator.goBack() + } + }, + ) +} + +@Composable +@Suppress("CyclomaticComplexMethod") +private fun GroupConversationDetailsNavigation3Entry( + route: GroupConversationDetailsRoute, + runtime: WireNavigation3Runtime, + actions: ConversationDetailsNavigation3Actions, +) { + val viewModel = groupConversationDetailsViewModel(route.toViewModelArgs()) + var renameRequest by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + var channelRequest by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + var folderRequest by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + var renameResult by rememberSaveable(route.entryId.value) { mutableStateOf(null) } + val snackbarHostState = LocalSnackbarHostState.current + val currentEntryId = runtime.navigator.currentRoute?.entryId + + LaunchedEffect(renameRequest, channelRequest, folderRequest, currentEntryId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + renameRequest?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, EditConversationNameNavigation3ResultType)) { + is WireNavResult.Value -> { + renameResult = result.value.succeeded + renameRequest = null + } + WireNavResult.Canceled -> renameRequest = null + null -> Unit + } + } + channelRequest?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, ChannelAccessUpdateNavigation3ResultType)) { + is WireNavResult.Value -> { + viewModel.updateChannelAccess(result.value.accessType.toLegacy()) + viewModel.updateChannelAddPermission(result.value.permissionType.toLegacy()) + channelRequest = null + } + WireNavResult.Canceled -> channelRequest = null + null -> Unit + } + } + folderRequest?.let(::WireNavResultRequestId)?.let { requestId -> + when (val result = runtime.consumeResult(requestId, ConversationFoldersNavigation3ResultType)) { + is WireNavResult.Value -> { + // The snackbar is the durable fallback; no pre-recreation callback is required. + snackbarHostState.showSnackbar(result.value.message) + folderRequest = null + } + WireNavResult.Canceled -> folderRequest = null + null -> Unit + } + } + } + + GroupConversationDetailsRouteScreen( + viewModel = viewModel, + renameResult = renameResult, + actions = GroupConversationDetailsRouteScreenActions( + onBackPressed = { + if (!runtime.navigator.goBack()) actions.exitDetails() + }, + onProfilePressed = { actions.openParticipantProfile(it, route.conversationId) }, + onAddParticipants = { apps, team, protocol, newUi -> + actions.openAddMembers(route.conversationId, apps, team, protocol, newUi) + }, + onEditGuestAccess = { guests, services, updating -> + runtime.navigator.navigate( + WireNavigationCommand( + EditGuestAccessRoute(route.sessionId, route.conversationId, guests, services, updating) + ) + ) + }, + onAppsAccess = { guests, apps, newUi -> + runtime.navigator.navigate( + WireNavigationCommand( + UpdateAppsAccessRoute(route.sessionId, route.conversationId, guests, apps, newUi) + ) + ) + }, + onChannelAccess = { access, permission -> + channelRequest = runtime.navigateForResult( + ChannelAccessOnUpdateRoute( + route.sessionId, + route.conversationId, + access.toNavigation3(), + permission.toNavigation3(), + ), + ChannelAccessUpdateNavigation3ResultType, + )?.value + }, + onEditSelfDeletingMessages = { + runtime.navigator.navigate( + WireNavigationCommand(EditSelfDeletingMessagesRoute(route.sessionId, route.conversationId)) + ) + }, + onEditGroupName = { + renameResult = null + renameRequest = runtime.navigateForResult( + EditConversationNameRoute(route.sessionId, route.conversationId), + EditConversationNameNavigation3ResultType, + )?.value + }, + onSearchConversationMessages = { cells, name -> + actions.openSearchMessages(route.conversationId, cells, name) + }, + onConversationMedia = { cells, name -> + actions.openConversationMedia(route.conversationId, cells, name) + }, + onMoveToFolder = { args, _ -> + folderRequest = runtime.navigateForResult( + ConversationFoldersRoute( + route.sessionId, + ConversationAuxId(args.conversationId.value, args.conversationId.domain), + args.conversationName, + args.currentFolderId, + ), + ConversationFoldersNavigation3ResultType, + )?.value + }, + onConversationCompleted = { action, name -> + val legacy = GroupConversationDetailsNavBackArgs(action, name) + if (!runtime.completeCurrentAndPop( + ConversationCompletionNavigation3ResultType, + WireNavResult.Value(legacy.toNavigation3()), + ) + ) { + actions.completeDetails(action, name) + } + }, + onPromoteAdmin = actions::openPromoteAdmin, + onOpenConversationDebugMenu = actions::openConversationDebugMenu, + ), + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Mappers.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Mappers.kt new file mode 100644 index 00000000000..3a23d50ee0d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Mappers.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +@file:Suppress("TooManyFunctions") + +package com.wire.android.ui.home.conversations.details + +import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessNavArgs +import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessParams +import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkNavArgs +import com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesNavArgs +import com.wire.android.ui.home.conversations.details.metadata.EditConversationNameNavArgs +import com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsNavArgs +import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessNavArgs +import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessParams +import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessArgs +import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType +import com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType +import com.wire.kalium.logic.data.id.QualifiedID + +internal fun ConversationDetailsId.toQualifiedId() = QualifiedID(value, domain) +internal fun QualifiedID.toConversationDetailsId() = ConversationDetailsId(value, domain) + +internal fun GroupConversationDetailsRoute.toViewModelArgs() = + GroupConversationDetailsNavArgs(conversationId.toQualifiedId()) + +internal fun EditConversationNameRoute.toViewModelArgs() = + EditConversationNameNavArgs(conversationId.toQualifiedId()) + +internal fun EditSelfDeletingMessagesRoute.toViewModelArgs() = + EditSelfDeletingMessagesNavArgs(conversationId.toQualifiedId()) + +internal fun GroupConversationAllParticipantsRoute.toViewModelArgs() = + GroupConversationAllParticipantsNavArgs(conversationId.toQualifiedId()) + +internal fun UpdateAppsAccessRoute.toViewModelArgs() = UpdateAppsAccessNavArgs( + conversationId = conversationId.toQualifiedId(), + updateAppsAccessParams = UpdateAppsAccessParams( + isGuestAllowed = isGuestAllowed, + isAppsAllowed = isAppsAllowed, + shouldUseNewAppsUi = shouldUseNewAppsUi, + ), +) + +internal fun ChannelAccessOnUpdateRoute.toViewModelArgs() = UpdateChannelAccessArgs( + conversationId = conversationId.toQualifiedId().toString(), + accessType = accessType.toLegacy(), + permissionType = permissionType.toLegacy(), +) + +internal fun EditGuestAccessRoute.toViewModelArgs() = EditGuestAccessNavArgs( + conversationId = conversationId.toQualifiedId(), + editGuessAccessParams = EditGuestAccessParams( + isGuestAccessAllowed = isGuestAccessAllowed, + isServicesAllowed = isServicesAllowed, + isUpdatingGuestAccessAllowed = isUpdatingGuestAccessAllowed, + ), +) + +internal fun CreatePasswordProtectedGuestLinkRoute.toViewModelArgs() = + CreatePasswordGuestLinkNavArgs(conversationId.toQualifiedId()) + +internal fun ChannelAccessSelection.toLegacy() = when (this) { + ChannelAccessSelection.PUBLIC -> ChannelAccessType.PUBLIC + ChannelAccessSelection.PRIVATE -> ChannelAccessType.PRIVATE +} + +internal fun ChannelPermissionSelection.toLegacy() = when (this) { + ChannelPermissionSelection.ADMINS -> ChannelAddPermissionType.ADMINS + ChannelPermissionSelection.EVERYONE -> ChannelAddPermissionType.EVERYONE +} + +internal fun ChannelAccessType.toNavigation3() = when (this) { + ChannelAccessType.PUBLIC -> ChannelAccessSelection.PUBLIC + ChannelAccessType.PRIVATE -> ChannelAccessSelection.PRIVATE +} + +internal fun ChannelAddPermissionType.toNavigation3() = when (this) { + ChannelAddPermissionType.ADMINS -> ChannelPermissionSelection.ADMINS + ChannelAddPermissionType.EVERYONE -> ChannelPermissionSelection.EVERYONE +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index 930e4fad53c..ec7cf61f94e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -61,15 +61,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.wire.android.R -import com.wire.android.appLogger -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.result.ResultRecipient -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.CollapsingTopBarScaffold import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.LoadingWireTabRow @@ -91,43 +82,22 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.WireTopAppBarTitle import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.AddMembersSearchScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnUpdateScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationMediaScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DebugConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.EditConversationNameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.EditGuestAccessScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.EditSelfDeletingMessagesScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SearchConversationMessagesScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.UpdateAppsAccessScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination -import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessParams -import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs -import com.wire.android.ui.home.conversations.promoteadmin.toPromoteAdminEligibleMemberArgs import com.wire.android.ui.home.conversations.details.options.GroupConversationOptions import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState import com.wire.android.ui.home.conversations.details.options.LoadingGroupConversation import com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipants import com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant -import com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessParams -import com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessArgs import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs -import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavBackArgs -import com.wire.android.ui.home.conversations.groupConversationDetailsViewModel import com.wire.android.ui.home.conversations.info.ConversationAvatar import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType +import com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectConversationDialog import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation @@ -137,52 +107,29 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -@Suppress("CyclomaticComplexMethod") -@SuppressLint("ComposeViewModelForwarding") -@WireRootDestination( - navArgs = GroupConversationDetailsNavArgs::class, - style = PopUpNavigationAnimation::class, // default should be PopUpNavigationAnimation -) +/** + * Navigation-neutral renderer used by the typed Navigation 3 entry. + * + * Navigation callbacks and typed results are supplied by the Navigation 3 entry. + */ +@Suppress("ComposeViewModelForwarding") @Composable -fun GroupConversationDetailsScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - groupConversationDetailResultRecipient: ResultRecipient, - editChannelAccessResultRecipient: ResultRecipient, - conversationFoldersScreenResultRecipient: - ResultRecipient, - viewModel: GroupConversationDetailsViewModel = groupConversationDetailsViewModel(), +internal fun GroupConversationDetailsRouteScreen( + viewModel: GroupConversationDetailsViewModel, + actions: GroupConversationDetailsRouteScreenActions, + renameResult: Boolean? = null, ) { val scope = rememberCoroutineScope() val resources = LocalContext.current.resources val snackbarHostState = LocalSnackbarHostState.current val sheetState = rememberWireModalSheetState() val groupOptions by viewModel.groupOptionsState.collectAsStateWithLifecycle() + val renameFailureMessage = stringResource(id = R.string.error_unknown_message) + val renameSuccessMessage = stringResource(id = R.string.conversation_options_renamed) - val onSearchConversationMessagesClick: () -> Unit = { - navigator.navigate( - NavigationCommand( - SearchConversationMessagesScreenDestination( - conversationId = viewModel.conversationId, - isCellsConversation = groupOptions.isWireCellEnabled && groupOptions.isWireCellFeatureEnabled, - groupName = groupOptions.groupName, - ) - ) - ) - } - - val onConversationMediaClick: () -> Unit = { - if (groupOptions.isWireCellEnabled) { - navigator.navigate( - NavigationCommand( - ConversationFilesScreenDestination( - conversationId = viewModel.conversationId.toString(), - breadcrumbs = arrayOf(groupOptions.groupName) - ) - ) - ) - } else { - navigator.navigate(NavigationCommand(ConversationMediaScreenDestination(viewModel.conversationId))) + LaunchedEffect(renameResult) { + renameResult?.let { + snackbarHostState.showSnackbar(if (it) renameSuccessMessage else renameFailureMessage) } } @@ -198,176 +145,85 @@ fun GroupConversationDetailsScreen( sheetState = sheetState, groupConversationOptionsState = groupOptions, viewModel = viewModel, - onBackPressed = navigator::navigateBack, - onProfilePressed = { participant -> - when { - participant.isSelf -> navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) - participant.isService && participant.botService != null -> - navigator.navigate( - NavigationCommand( - ServiceDetailsScreenDestination( - viewModel.conversationId, - ServiceDetailsNavArgs.Id.BotServiceId(participant.botService) - ) - ) - ) - participant.isService -> - navigator.navigate( - NavigationCommand( - ServiceDetailsScreenDestination( - viewModel.conversationId, - ServiceDetailsNavArgs.Id.AppId(participant.id) - ) - ) - ) - - else -> navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(participant.id, viewModel.conversationId))) - } - }, + onBackPressed = actions.onBackPressed, + onProfilePressed = actions.onProfilePressed, onAddParticipantsPressed = { - navigator.navigate( - NavigationCommand( - AddMembersSearchScreenDestination( - conversationId = viewModel.conversationId, - isConversationAppsEnabled = groupOptions.isAppsAllowed, - isSelfPartOfATeam = groupOptions.isSelfPartOfATeam, - protocolInfo = groupOptions.protocolInfo, - shouldUseNewAppsUi = groupOptions.shouldUseNewAppsUi - ) - ) + actions.onAddParticipants( + groupOptions.isAppsAllowed, + groupOptions.isSelfPartOfATeam, + groupOptions.protocolInfo, + groupOptions.shouldUseNewAppsUi, ) }, groupParticipantsState = viewModel.groupParticipantsState, onEditGuestAccess = { - navigator.navigate( - NavigationCommand( - EditGuestAccessScreenDestination( - viewModel.conversationId, - EditGuestAccessParams( - groupOptions.isGuestAllowed, - groupOptions.isAppsAllowed, - groupOptions.isUpdatingGuestAllowed - ) - ) - ) + actions.onEditGuestAccess( + groupOptions.isGuestAllowed, + groupOptions.isAppsAllowed, + groupOptions.isUpdatingGuestAllowed, ) }, onAppsAccessItemClicked = { - navigator.navigate( - NavigationCommand( - UpdateAppsAccessScreenDestination( - viewModel.conversationId, - UpdateAppsAccessParams( - isGuestAllowed = groupOptions.isGuestAllowed, - isAppsAllowed = groupOptions.isAppsAllowed, - shouldUseNewAppsUi = groupOptions.shouldUseNewAppsUi - ) - ) - ) + actions.onAppsAccess( + groupOptions.isGuestAllowed, + groupOptions.isAppsAllowed, + groupOptions.shouldUseNewAppsUi, ) }, onChannelAccessItemClicked = { - navigator.navigate( - NavigationCommand( - ChannelAccessOnUpdateScreenDestination( - viewModel.conversationId.toString(), - groupOptions.channelAccessType!!, - groupOptions.channelAddPermissionType!! - ) - ) + actions.onChannelAccess( + checkNotNull(groupOptions.channelAccessType), + checkNotNull(groupOptions.channelAddPermissionType), ) }, - onEditSelfDeletingMessages = { - navigator.navigate(NavigationCommand(EditSelfDeletingMessagesScreenDestination(viewModel.conversationId))) + onEditSelfDeletingMessages = actions.onEditSelfDeletingMessages, + onEditGroupName = actions.onEditGroupName, + isWireCellEnabled = groupOptions.isWireCellEnabled, + onSearchConversationMessagesClick = { + actions.onSearchConversationMessages( + groupOptions.isWireCellEnabled && groupOptions.isWireCellFeatureEnabled, + groupOptions.groupName, + ) }, - onEditGroupName = { - navigator.navigate(NavigationCommand(EditConversationNameScreenDestination(viewModel.conversationId))) + onConversationMediaClick = { + actions.onConversationMedia(groupOptions.isWireCellEnabled, groupOptions.groupName) }, - isWireCellEnabled = groupOptions.isWireCellEnabled, - onSearchConversationMessagesClick = onSearchConversationMessagesClick, - onConversationMediaClick = onConversationMediaClick, - isAbandonedOneOnOneConversation = groupOptions.isAbandonedOneOnOneConversation(viewModel.groupParticipantsState.data.allCount), - onMoveToFolder = { - navigator.navigate(NavigationCommand(ConversationFoldersScreenDestination(it))) + isAbandonedOneOnOneConversation = + groupOptions.isAbandonedOneOnOneConversation(viewModel.groupParticipantsState.data.allCount), + onMoveToFolder = { args -> + actions.onMoveToFolder(args) { message -> + scope.launch { snackbarHostState.showSnackbar(message) } + } }, onLeftConversation = { - resultNavigator.navigateBack( - GroupConversationDetailsNavBackArgs( - groupConversationActionType = GroupConversationActionType.LEAVE_GROUP, - conversationName = groupOptions.groupName - ) - ) + actions.onConversationCompleted(GroupConversationActionType.LEAVE_GROUP, groupOptions.groupName) }, onDeletedConversation = { - resultNavigator.navigateBack( - GroupConversationDetailsNavBackArgs( - groupConversationActionType = GroupConversationActionType.DELETE_GROUP, - conversationName = groupOptions.groupName - ) - ) - }, - onPromoteAdmin = { conversationId, eligibleMembers -> - navigator.navigate( - NavigationCommand( - PromoteAdminScreenDestination( - PromoteAdminNavArgs(conversationId, eligibleMembers.toPromoteAdminEligibleMemberArgs()) - ) - ) - ) - }, - openConversationDebugMenu = { - navigator.navigate( - NavigationCommand( - DebugConversationScreenDestination(conversationId = it) - ) - ) + actions.onConversationCompleted(GroupConversationActionType.DELETE_GROUP, groupOptions.groupName) }, - isScreenLoading = viewModel.isFetchingInitialData + onPromoteAdmin = actions.onPromoteAdmin, + openConversationDebugMenu = actions.onOpenConversationDebugMenu, + isScreenLoading = viewModel.isFetchingInitialData, ) - - val tryAgainSnackBarMessage = stringResource(id = R.string.error_unknown_message) - val successSnackBarMessage = stringResource(id = R.string.conversation_options_renamed) - - groupConversationDetailResultRecipient.onNavResult { result -> - when (result) { - is NavResult.Canceled -> { - appLogger.i("Error with receiving navigation back args from editGroupName in GroupConversationDetailsScreen") - } - - is NavResult.Value -> { - scope.launch { - if (result.value) { - snackbarHostState.showSnackbar(successSnackBarMessage) - } else { - snackbarHostState.showSnackbar(tryAgainSnackBarMessage) - } - } - } - } - } - - conversationFoldersScreenResultRecipient.onNavResult { result -> - when (result) { - NavResult.Canceled -> {} - is NavResult.Value -> { - scope.launch { - snackbarHostState.showSnackbar(result.value.message) - } - } - } - } - - editChannelAccessResultRecipient.onNavResult { result -> - when (result) { - NavResult.Canceled -> {} - is NavResult.Value -> { - viewModel.updateChannelAccess(result.value.accessType) - viewModel.updateChannelAddPermission(result.value.permissionType) - } - } - } } +internal data class GroupConversationDetailsRouteScreenActions( + val onBackPressed: () -> Unit, + val onProfilePressed: (UIParticipant) -> Unit, + val onAddParticipants: (Boolean, Boolean, Conversation.ProtocolInfo, Boolean) -> Unit, + val onEditGuestAccess: (Boolean, Boolean, Boolean) -> Unit, + val onAppsAccess: (Boolean, Boolean, Boolean) -> Unit, + val onChannelAccess: (ChannelAccessType, ChannelAddPermissionType) -> Unit, + val onEditSelfDeletingMessages: () -> Unit, + val onEditGroupName: () -> Unit, + val onSearchConversationMessages: (Boolean, String) -> Unit, + val onConversationMedia: (Boolean, String) -> Unit, + val onMoveToFolder: (ConversationFoldersNavArgs, onMessage: (String) -> Unit) -> Unit, + val onConversationCompleted: (GroupConversationActionType, String) -> Unit, + val onPromoteAdmin: (ConversationId, List) -> Unit, + val onOpenConversationDebugMenu: (ConversationId) -> Unit, +) + @Suppress("CyclomaticComplexMethod") @SuppressLint("ComposeViewModelForwarding") @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt index 68b2509a0b7..44db84acd88 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt @@ -18,9 +18,7 @@ package com.wire.android.ui.home.conversations.details -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.appLogger import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState @@ -75,23 +73,22 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( private val updateConversationReceiptMode: UpdateConversationReceiptModeUseCase, private val observeSelfDeletionTimerSettingsForConversation: ObserveSelfDeletionTimerSettingsForConversationUseCase, private val observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, - @Assisted savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: GroupConversationDetailsNavArgs, private val isMLSEnabled: IsMLSEnabledUseCase, refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val isWireCellsEnabled: IsWireCellsEnabledUseCase, ) : ActionsViewModel(), GroupConversationParticipantsManager by GroupConversationParticipantsManagerImpl( - savedStateHandle = savedStateHandle, + conversationId = navigationArgs.conversationId, observeConversationMembers = observeConversationMembers, refreshUsersWithoutMetadata = refreshUsersWithoutMetadata ) { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): GroupConversationDetailsViewModel + fun create(navigationArgs: GroupConversationDetailsNavArgs): GroupConversationDetailsViewModel } - private val groupConversationDetailsNavArgs: GroupConversationDetailsNavArgs = savedStateHandle.navArgs() - val conversationId: QualifiedID = groupConversationDetailsNavArgs.conversationId + val conversationId: QualifiedID = navigationArgs.conversationId private val _groupOptionsState = MutableStateFlow(GroupConversationOptionsState(conversationId)) val groupOptionsState: StateFlow = _groupOptionsState diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessScreen.kt index c2bfd68497a..272e24dd4ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessScreen.kt @@ -20,7 +20,6 @@ package com.wire.android.ui.home.conversations.details.editguestaccess import com.wire.android.ui.home.conversations.editGuestAccessViewModel -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -40,11 +39,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.dimensions @@ -53,8 +48,6 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.CreatePasswordProtectedGuestLinkScreenDestination -import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkNavArgs import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -62,16 +55,13 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.copyLinkToClipboard import com.wire.android.util.shareViaIntent -@Suppress("ComplexMethod") -@WireRootDestination( - navArgs = EditGuestAccessNavArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun EditGuestAccessScreen( - navigator: Navigator, +@Suppress("CyclomaticComplexMethod") +internal fun EditGuestAccessRouteScreen( + viewModel: EditGuestAccessViewModel, + onNavigateBack: () -> Unit, + onCreatePasswordProtectedLink: () -> Unit, modifier: Modifier = Modifier, - editGuestAccessViewModel: EditGuestAccessViewModel = editGuestAccessViewModel() ) { val scrollState = rememberScrollState() val snackbarHostState = LocalSnackbarHostState.current @@ -79,23 +69,15 @@ fun EditGuestAccessScreen( val onSheetItemClick: (Boolean) -> Unit = { isPasswordProtected -> sheetState.hide() if (isPasswordProtected) { - navigator.navigate( - NavigationCommand( - CreatePasswordProtectedGuestLinkScreenDestination( - CreatePasswordGuestLinkNavArgs( - conversationId = editGuestAccessViewModel.conversationId - ) - ) - ) - ) + onCreatePasswordProtectedLink() } else { - editGuestAccessViewModel.onRequestGuestRoomLink() + viewModel.onRequestGuestRoomLink() } } CreateGuestLinkBottomSheet( sheetState = sheetState, onItemClick = remember { onSheetItemClick }, - isPasswordInviteLinksAllowed = editGuestAccessViewModel.editGuestAccessState.isPasswordProtectedLinksAllowed + isPasswordInviteLinksAllowed = viewModel.editGuestAccessState.isPasswordProtectedLinksAllowed ) WireScaffold( @@ -105,7 +87,7 @@ fun EditGuestAccessScreen( WireCenterAlignedTopAppBar( elevation = scrollState.rememberTopBarElevationState().value, navigationIconType = NavigationIconType.Back(R.string.content_description_edit_guests_option_back_btn), - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onNavigateBack, title = title ) } @@ -119,7 +101,7 @@ fun EditGuestAccessScreen( .fillMaxSize() ) { item { - with(editGuestAccessViewModel) { + with(viewModel) { GuestOption( isSwitchEnabled = editGuestAccessState.isUpdatingGuestAccessAllowed, isSwitchVisible = true, @@ -149,7 +131,7 @@ fun EditGuestAccessScreen( top = dimensions().spacing8x, ) ) { - with(editGuestAccessViewModel) { + with(viewModel) { Text( text = stringResource(id = R.string.guest_link_description), style = MaterialTheme.wireTypography.body01, @@ -175,7 +157,7 @@ fun EditGuestAccessScreen( val clipboardManager = LocalClipboardManager.current val context = LocalContext.current - with(editGuestAccessViewModel) { + with(viewModel) { GuestLinkActionButtons( shouldDisableGenerateGuestLinkButton = shouldDisableGenerateGuestLinkButton(), isGeneratingLink = editGuestAccessState.isGeneratingGuestRoomLink, @@ -201,7 +183,7 @@ fun EditGuestAccessScreen( } } - with(editGuestAccessViewModel) { + with(viewModel) { if (editGuestAccessState.shouldShowGuestAccessChangeConfirmationDialog) { DisableGuestConfirmationDialog( onConfirm = ::onGuestDialogConfirm, @@ -239,5 +221,9 @@ fun EditGuestAccessScreen( @Preview @Composable fun PreviewEditGuestAccessScreen() { - EditGuestAccessScreen(rememberNavigator {}) + EditGuestAccessRouteScreen( + viewModel = editGuestAccessViewModel(), + onNavigateBack = {}, + onCreatePasswordProtectedLink = {}, + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModel.kt index e58e3e0bd9b..1ccc973f595 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModel.kt @@ -21,13 +21,11 @@ package com.wire.android.ui.home.conversations.details.editguestaccess import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails @@ -76,16 +74,15 @@ class EditGuestAccessViewModel @AssistedInject constructor( private val syncConversationCode: SyncConversationCodeUseCase, private val getDefaultProtocol: GetDefaultProtocolUseCase, private val selfUser: ObserveSelfUserUseCase, - @Assisted savedStateHandle: SavedStateHandle + @Assisted navigationArgs: EditGuestAccessNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): EditGuestAccessViewModel + fun create(navigationArgs: EditGuestAccessNavArgs): EditGuestAccessViewModel } - private val editGuestAccessNavArgs: EditGuestAccessNavArgs = savedStateHandle.navArgs() - val conversationId: QualifiedID = editGuestAccessNavArgs.conversationId - private val accessParams = editGuestAccessNavArgs.editGuessAccessParams + val conversationId: QualifiedID = navigationArgs.conversationId + private val accessParams = navigationArgs.editGuessAccessParams var editGuestAccessState by mutableStateOf( EditGuestAccessState( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordGuestLinkViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordGuestLinkViewModel.kt index 1b3ecd70860..dedfde33e2d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordGuestLinkViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordGuestLinkViewModel.kt @@ -22,11 +22,9 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.common.textfield.textAsFlow -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkResult @@ -44,15 +42,14 @@ class CreatePasswordGuestLinkViewModel @AssistedInject constructor( private val generateGuestRoomLink: GenerateGuestRoomLinkUseCase, private val validatePassword: ValidatePasswordUseCase, private val generatePassword: RandomPassword, - @Assisted savedStateHandle: SavedStateHandle + @Assisted navigationArgs: CreatePasswordGuestLinkNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreatePasswordGuestLinkViewModel + fun create(navigationArgs: CreatePasswordGuestLinkNavArgs): CreatePasswordGuestLinkViewModel } - private val editGuestAccessNavArgs: CreatePasswordGuestLinkNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = editGuestAccessNavArgs.conversationId + private val conversationId: QualifiedID = navigationArgs.conversationId var state by mutableStateOf(CreatePasswordGuestLinkState()) @VisibleForTesting set diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordProtectedGuestLinkScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordProtectedGuestLinkScreen.kt index f474b4ecd1d..848757cb58a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordProtectedGuestLinkScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/createPasswordProtectedGuestLink/CreatePasswordProtectedGuestLinkScreen.kt @@ -17,9 +17,6 @@ */ package com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink -import com.wire.android.ui.home.conversations.createPasswordGuestLinkViewModel - -import com.wire.android.navigation.annotation.app.WireRootDestination import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -49,7 +46,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.button.GeneratePasswordButton import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -68,19 +64,16 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import kotlinx.coroutines.launch -@WireRootDestination( - navArgs = CreatePasswordGuestLinkNavArgs::class -) @Composable -fun CreatePasswordProtectedGuestLinkScreen( - navigator: Navigator, - viewModel: CreatePasswordGuestLinkViewModel = createPasswordGuestLinkViewModel(), +internal fun CreatePasswordProtectedGuestLinkRouteScreen( + viewModel: CreatePasswordGuestLinkViewModel, + navigateBack: () -> Unit, ) { CreatePasswordProtectedGuestLinkScreenContent( state = viewModel.state, passwordTextState = viewModel.state.passwordTextState, confirmPasswordTextState = viewModel.state.confirmPasswordTextState, - navigateBack = navigator::navigateBack, + navigateBack = navigateBack, onGenerateRandomPassword = viewModel::onGenerateRandomPassword, onGenerateLink = viewModel::onGenerateLink, onErrorDialogDismissed = viewModel::onErrorDialogDismissed, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesScreen.kt index f5130f7610b..bfa9ee315c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesScreen.kt @@ -20,7 +20,6 @@ package com.wire.android.ui.home.conversations.details.editselfdeletingmessages import com.wire.android.ui.home.conversations.editSelfDeletingMessagesViewModel -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable @@ -42,10 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.WireRadioButton import com.wire.android.ui.common.button.WireButton import com.wire.android.ui.common.button.WireButtonState @@ -62,14 +58,10 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.sectionWithElements -@WireRootDestination( - navArgs = EditSelfDeletingMessagesNavArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun EditSelfDeletingMessagesScreen( - navigator: Navigator, - editSelfDeletingMessagesViewModel: EditSelfDeletingMessagesViewModel = editSelfDeletingMessagesViewModel(), +internal fun EditSelfDeletingMessagesRouteScreen( + viewModel: EditSelfDeletingMessagesViewModel, + onNavigateBack: () -> Unit, ) { val scrollState = rememberScrollState() val context = LocalContext.current @@ -79,15 +71,15 @@ fun EditSelfDeletingMessagesScreen( val title = stringResource(id = R.string.self_deleting_messages_title) WireCenterAlignedTopAppBar( elevation = scrollState.rememberTopBarElevationState().value, - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onNavigateBack, title = title, navigationIconType = NavigationIconType.Back(R.string.content_description_edit_self_delete_back_btn) ) } ) { internalPadding -> - with(editSelfDeletingMessagesViewModel) { + with(viewModel) { LaunchedEffect(state.isCompleted) { - if (state.isCompleted) navigator.navigateBack() + if (state.isCompleted) onNavigateBack() } Column(modifier = Modifier.padding(internalPadding)) { SelfDeletingMessageOption( @@ -175,7 +167,10 @@ fun SelectableSelfDeletingItem( @Preview @Composable fun PreviewEditSelfDeletingMessagesScreen() { - EditSelfDeletingMessagesScreen(rememberNavigator {}) + EditSelfDeletingMessagesRouteScreen( + viewModel = editSelfDeletingMessagesViewModel(), + onNavigateBack = {}, + ) } @Preview diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModel.kt index d3d426c4d3d..1c07e38e283 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModel.kt @@ -21,14 +21,12 @@ package com.wire.android.ui.home.conversations.details.editselfdeletingmessages import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.QualifiedID @@ -54,15 +52,14 @@ class EditSelfDeletingMessagesViewModel @AssistedInject constructor( private val updateMessageTimer: UpdateMessageTimerUseCase, private val selfUser: ObserveSelfUserUseCase, private val conversationDetails: ObserveConversationDetailsUseCase, - @Assisted savedStateHandle: SavedStateHandle + @Assisted navigationArgs: EditSelfDeletingMessagesNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): EditSelfDeletingMessagesViewModel + fun create(navigationArgs: EditSelfDeletingMessagesNavArgs): EditSelfDeletingMessagesViewModel } - private val editSelfDeletingMessagesNavArgs: EditSelfDeletingMessagesNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = editSelfDeletingMessagesNavArgs.conversationId + private val conversationId: QualifiedID = navigationArgs.conversationId var state by mutableStateOf( EditSelfDeletingMessagesState() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModel.kt index e1d36bf774c..64947259a97 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModel.kt @@ -23,10 +23,8 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.common.groupname.GroupMetadataState import com.wire.android.ui.common.groupname.GroupNameMode import com.wire.android.ui.common.groupname.GroupNameValidator @@ -52,15 +50,14 @@ class EditConversationMetadataViewModel @AssistedInject constructor( private val dispatcher: DispatcherProvider, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val renameConversation: RenameConversationUseCase, - @Assisted val savedStateHandle: SavedStateHandle + @Assisted navigationArgs: EditConversationNameNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): EditConversationMetadataViewModel + fun create(navigationArgs: EditConversationNameNavArgs): EditConversationMetadataViewModel } - private val editConversationNameNavArgs: EditConversationNameNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = editConversationNameNavArgs.conversationId + private val conversationId: QualifiedID = navigationArgs.conversationId val editConversationNameTextState: TextFieldState = TextFieldState() var editConversationState by mutableStateOf(GroupMetadataState(mode = GroupNameMode.EDITION)) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationNameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationNameScreen.kt index 4fbef87fc57..462533e6cbd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationNameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationNameScreen.kt @@ -18,42 +18,26 @@ package com.wire.android.ui.home.conversations.details.metadata -import com.wire.android.ui.home.conversations.editConversationMetadataViewModel - -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.wire.android.navigation.style.SlideNavigationAnimation -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.groupname.GroupMetadataState import com.wire.android.ui.common.groupname.GroupNameMode import com.wire.android.ui.common.groupname.GroupNameScreen import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination( - navArgs = EditConversationNameNavArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun EditConversationNameScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - viewModel: EditConversationMetadataViewModel = editConversationMetadataViewModel(), +internal fun EditConversationNameRouteScreen( + viewModel: EditConversationMetadataViewModel, + onNavigateBack: () -> Unit, + onCompleted: (Boolean) -> Unit, ) { with(viewModel) { LaunchedEffect(editConversationState.completed) { when (editConversationState.completed) { - GroupMetadataState.Completed.Success -> { - resultNavigator.setResult(true) - resultNavigator.navigateBack() - } - GroupMetadataState.Completed.Failure -> { - resultNavigator.setResult(false) - resultNavigator.navigateBack() - } + GroupMetadataState.Completed.Success -> onCompleted(true) + GroupMetadataState.Completed.Failure -> onCompleted(false) GroupMetadataState.Completed.None -> Unit // No action needed } } @@ -62,7 +46,7 @@ fun EditConversationNameScreen( newGroupNameTextState = editConversationNameTextState, onGroupNameErrorAnimated = ::onGroupNameErrorAnimated, onContinuePressed = ::saveNewGroupName, - onBackPressed = navigator::navigateBack + onBackPressed = onNavigateBack, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt index fcddb0dedec..6389fd6b076 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationAllParticipantsScreen.kt @@ -18,9 +18,6 @@ package com.wire.android.ui.home.conversations.details.participants -import com.wire.android.ui.home.conversations.groupConversationParticipantsViewModel - -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.layout.fillMaxHeight @@ -37,57 +34,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination import com.wire.android.ui.home.conversations.details.participants.model.ParticipantsExpansionState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant import com.wire.android.ui.theme.WireTheme -import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs -@WireRootDestination( - navArgs = GroupConversationAllParticipantsNavArgs::class -) @Composable -fun GroupConversationAllParticipantsScreen( - navigator: Navigator, - navArgs: GroupConversationAllParticipantsNavArgs, - viewModel: GroupConversationParticipantsViewModel = groupConversationParticipantsViewModel() +internal fun GroupConversationAllParticipantsRouteScreen( + viewModel: GroupConversationParticipantsViewModel, + onBackPressed: () -> Unit, + onProfilePressed: (UIParticipant) -> Unit, ) { GroupConversationAllParticipantsContent( - onBackPressed = navigator::navigateBack, + onBackPressed = onBackPressed, groupParticipantsState = viewModel.groupParticipantsState, - onProfilePressed = { participant -> - when { - participant.isSelf -> navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) - participant.isService && participant.botService != null -> - navigator.navigate( - NavigationCommand( - ServiceDetailsScreenDestination( - navArgs.conversationId, - ServiceDetailsNavArgs.Id.BotServiceId(participant.botService) - ) - ) - ) - participant.isService -> - navigator.navigate( - NavigationCommand( - ServiceDetailsScreenDestination( - navArgs.conversationId, - ServiceDetailsNavArgs.Id.AppId(participant.id) - ) - ) - ) - - else -> navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(participant.id, navArgs.conversationId))) - } - }, + onProfilePressed = onProfilePressed, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModel.kt index c2c5ad9367f..f71243ea631 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModel.kt @@ -21,10 +21,8 @@ package com.wire.android.ui.home.conversations.details.participants import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase @@ -34,13 +32,13 @@ import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch class GroupConversationParticipantsViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: GroupConversationAllParticipantsNavArgs, private val observeConversationMembers: ObserveParticipantsForConversationUseCase, private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, maxNumberOfItems: Int = -1, // -1 means return whole list ) : ViewModel(), GroupConversationParticipantsManager by GroupConversationParticipantsManagerImpl( - savedStateHandle = savedStateHandle, + conversationId = navigationArgs.conversationId, observeConversationMembers = observeConversationMembers, refreshUsersWithoutMetadata = refreshUsersWithoutMetadata, maxNumberOfItems = maxNumberOfItems @@ -48,7 +46,7 @@ class GroupConversationParticipantsViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): GroupConversationParticipantsViewModel + fun create(navigationArgs: GroupConversationAllParticipantsNavArgs): GroupConversationParticipantsViewModel } } @@ -57,15 +55,12 @@ interface GroupConversationParticipantsManager { } class GroupConversationParticipantsManagerImpl( - savedStateHandle: SavedStateHandle, + private val conversationId: QualifiedID, private val observeConversationMembers: ObserveParticipantsForConversationUseCase, private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, val maxNumberOfItems: Int = -1 // -1 means return whole list ) : ViewModel(), GroupConversationParticipantsManager { override var groupParticipantsState: GroupConversationParticipantsState by mutableStateOf(GroupConversationParticipantsState()) - private val groupConversationAllParticipantsNavArgs: GroupConversationAllParticipantsNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = groupConversationAllParticipantsNavArgs.conversationId - init { runRefreshUsersWithoutMetadata() observeConversationMembers() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt index b26b0e0d1bf..14bf65a78d0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessScreen.kt @@ -17,8 +17,6 @@ */ package com.wire.android.ui.home.conversations.details.updateappsaccess -import com.wire.android.ui.home.conversations.updateAppsAccessViewModel - import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -32,9 +30,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState @@ -48,21 +43,17 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.util.ui.PreviewMultipleThemes import kotlinx.coroutines.launch -@WireRootDestination( - navArgs = UpdateAppsAccessNavArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun UpdateAppsAccessScreen( - navigator: Navigator, - updateAppsAccessViewModel: UpdateAppsAccessViewModel = updateAppsAccessViewModel() +internal fun UpdateAppsAccessRouteScreen( + viewModel: UpdateAppsAccessViewModel, + onNavigateBack: () -> Unit, ) { UpdateAppsAccessContent( - onNavigateBack = navigator::navigateBack, - onChangeAppAccess = updateAppsAccessViewModel::onAppsAccessUpdate, - onDisableAppsConfirm = updateAppsAccessViewModel::onServiceDialogConfirm, - onDisableAppsDismiss = updateAppsAccessViewModel::onAppsDialogDismiss, - state = updateAppsAccessViewModel.updateAppsAccessState, + onNavigateBack = onNavigateBack, + onChangeAppAccess = viewModel::onAppsAccessUpdate, + onDisableAppsConfirm = viewModel::onServiceDialogConfirm, + onDisableAppsDismiss = viewModel::onAppsDialogDismiss, + state = viewModel.updateAppsAccessState, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt index da15e7db400..666807d5487 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModel.kt @@ -20,11 +20,9 @@ package com.wire.android.ui.home.conversations.details.updateappsaccess import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.AppsUtil import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.conversation.Conversation @@ -59,16 +57,15 @@ class UpdateAppsAccessViewModel @AssistedInject constructor( private val observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, private val selfUser: ObserveSelfUserUseCase, private val changeAccessForAppsInConversation: ChangeAccessForAppsInConversationUseCase, - @Assisted savedStateHandle: SavedStateHandle + @Assisted navigationArgs: UpdateAppsAccessNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): UpdateAppsAccessViewModel + fun create(navigationArgs: UpdateAppsAccessNavArgs): UpdateAppsAccessViewModel } - private val updateAppsAccessNavArgs: UpdateAppsAccessNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = updateAppsAccessNavArgs.conversationId - private val currentAccessParams = updateAppsAccessNavArgs.updateAppsAccessParams + private val conversationId: QualifiedID = navigationArgs.conversationId + private val currentAccessParams = navigationArgs.updateAppsAccessParams val shouldUseNewAppsUi: Boolean = currentAccessParams.shouldUseNewAppsUi var updateAppsAccessState by mutableStateOf( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/ChannelAccessOnUpdateScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/ChannelAccessOnUpdateScreen.kt index 885c95fb76e..50283b7372d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/ChannelAccessOnUpdateScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/ChannelAccessOnUpdateScreen.kt @@ -17,14 +17,9 @@ */ package com.wire.android.ui.home.conversations.details.updatechannelaccess -import com.wire.android.ui.home.conversations.updateChannelAccessViewModel - -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.R import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold @@ -32,27 +27,18 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessScreenContent -@WireRootDestination( - navArgs = UpdateChannelAccessArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun ChannelAccessOnUpdateScreen( - resultNavigator: ResultBackNavigator, - updateChannelAccessViewModel: UpdateChannelAccessViewModel = updateChannelAccessViewModel() +internal fun ChannelAccessOnUpdateRouteScreen( + viewModel: UpdateChannelAccessViewModel, + onNavigateBack: (UpdateChannelAccessArgs) -> Unit, ) { - - fun navigateBack() { - resultNavigator.setResult( - UpdateChannelAccessArgs( - updateChannelAccessViewModel.conversationId, - updateChannelAccessViewModel.accessType, - updateChannelAccessViewModel.permissionType, - ) + fun navigateBack() = onNavigateBack( + UpdateChannelAccessArgs( + viewModel.conversationId, + viewModel.accessType, + viewModel.permissionType, ) - resultNavigator.navigateBack() - } - + ) WireScaffold( topBar = { WireCenterAlignedTopAppBar( @@ -64,7 +50,7 @@ fun ChannelAccessOnUpdateScreen( ) } ) { internalPadding -> - with(updateChannelAccessViewModel) { + with(viewModel) { ChannelAccessScreenContent( internalPadding = internalPadding, accessType, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModel.kt index 32e117d7e20..e0970bad132 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModel.kt @@ -20,13 +20,11 @@ package com.wire.android.ui.home.conversations.details.updatechannelaccess import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType import com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType import com.wire.android.ui.home.newconversation.channelaccess.toDomainEnum -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.id.toQualifiedID import com.wire.kalium.logic.feature.conversation.channel.UpdateChannelAddPermissionUseCase @@ -37,29 +35,27 @@ import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch class UpdateChannelAccessViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: UpdateChannelAccessArgs, val updateChannelAddPermission: UpdateChannelAddPermissionUseCase, private val qualifiedIdMapper: QualifiedIdMapper, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): UpdateChannelAccessViewModel + fun create(navigationArgs: UpdateChannelAccessArgs): UpdateChannelAccessViewModel } - private val channelAccessNavArgs: UpdateChannelAccessArgs = savedStateHandle.navArgs() - - var accessType: ChannelAccessType by mutableStateOf(channelAccessNavArgs.accessType) + var accessType: ChannelAccessType by mutableStateOf(navigationArgs.accessType) private set - var permissionType: ChannelAddPermissionType by mutableStateOf(channelAccessNavArgs.permissionType) + var permissionType: ChannelAddPermissionType by mutableStateOf(navigationArgs.permissionType) private set - val conversationId: String = channelAccessNavArgs.conversationId + val conversationId: String = navigationArgs.conversationId fun updateChannelAddPermission(newPermission: ChannelAddPermissionType) { viewModelScope.launch { val result = updateChannelAddPermission( - channelAccessNavArgs.conversationId.toQualifiedID(qualifiedIdMapper), + conversationId.toQualifiedID(qualifiedIdMapper), newPermission.toDomainEnum() ) when (result) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuViewModel.kt index 5af9d8d5508..98171e3105f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsMenuViewModel.kt @@ -23,7 +23,6 @@ import dev.zacsweers.metro.AssistedInject import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.di.ScopedArgs import com.wire.android.di.ViewModelScopedPreview import com.wire.android.ui.home.conversations.mock.mockMessageWithText import com.wire.android.ui.home.conversations.model.UIMessage @@ -83,13 +82,7 @@ class MessageOptionsMenuViewModelImpl @AssistedInject constructor( } @Serializable -data class MessageOptionsMenuArgs(val conversationId: QualifiedID) : ScopedArgs { - override val key = "$ARGS_KEY:$conversationId" - - companion object { - const val ARGS_KEY = "MessageOptionsMenuArgsKey" - } -} +data class MessageOptionsMenuArgs(val conversationId: QualifiedID) sealed interface MessageOptionsMenuState { data object Loading : MessageOptionsMenuState diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt index 6eabb7f2eff..a75f2b055ba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.conversations.folder -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,15 +34,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.ui.common.R as commonR import com.wire.android.model.Clickable -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.bottomsheet.RichMenuItemState import com.wire.android.ui.common.bottomsheet.SelectableMenuBottomSheetItem import com.wire.android.ui.common.button.WireButton @@ -56,61 +49,30 @@ import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography -import com.wire.android.ui.home.conversations.conversationFoldersViewModel -import com.wire.android.ui.home.conversations.moveConversationToFolderViewModel -import com.ramcosta.composedestinations.generated.app.destinations.NewConversationFolderScreenDestination import com.wire.kalium.logic.data.conversation.ConversationFolder -import com.wire.kalium.logic.data.conversation.FolderType -@WireRootDestination( - navArgs = ConversationFoldersNavArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun ConversationFoldersScreen( +internal fun ConversationFoldersRouteScreen( args: ConversationFoldersNavArgs, - navigator: Navigator, - resultNavigator: ResultBackNavigator, - resultRecipient: ResultRecipient, - foldersViewModel: ConversationFoldersVM = - conversationFoldersViewModel(ConversationFoldersStateArgs(args.currentFolderId)), - moveToFolderVM: MoveConversationToFolderVM = - moveConversationToFolderViewModel( - MoveConversationToFolderArgs(args.conversationId, args.conversationName, args.currentFolderId) - ) + foldersViewModel: ConversationFoldersVM, + moveToFolderViewModel: MoveConversationToFolderVM, + onNavigateBack: () -> Unit, + onCompleted: (String) -> Unit, + onCreateFolderPressed: () -> Unit, ) { val resources = LocalContext.current.resources - - LaunchedEffect(Unit) { - moveToFolderVM.infoMessage.collect { - resultNavigator.setResult(ConversationFoldersNavBackArgs(message = it.asString(resources))) - resultNavigator.navigateBack() - } + LaunchedEffect(moveToFolderViewModel) { + moveToFolderViewModel.infoMessage.collect { onCompleted(it.asString(resources)) } } Content( args = args, foldersState = foldersViewModel.state(), - onNavigationPressed = { navigator.navigateBack() }, - moveConversationToFolder = moveToFolderVM::moveConversationToFolder, + onNavigationPressed = onNavigateBack, + moveConversationToFolder = moveToFolderViewModel::moveConversationToFolder, onFolderSelected = foldersViewModel::onFolderSelected, - onCreateFolderPressed = { navigator.navigate(NavigationCommand(NewConversationFolderScreenDestination())) } + onCreateFolderPressed = onCreateFolderPressed, ) - - resultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> {} - is NavResult.Value -> { - moveToFolderVM.moveConversationToFolder( - ConversationFolder( - it.value.folderId, - it.value.folderName, - FolderType.USER - ) - ) - } - } - } } @Composable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt index 59bbab6771e..8c084299135 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.conversations.folder -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -36,10 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled @@ -53,7 +49,6 @@ import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.textfield.maxLengthWithCallback import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.wire.android.ui.home.conversations.newFolderViewModel import com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameViewModel.Companion.NAME_MAX_COUNT import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -61,19 +56,15 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler -@WireRootDestination( - style = SlideNavigationAnimation::class, -) @Composable -fun NewConversationFolderScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - viewModel: NewFolderViewModel = newFolderViewModel() +internal fun NewConversationFolderRouteScreen( + viewModel: NewFolderViewModel, + onBackPressed: () -> Unit, + onFolderCreated: (NewConversationFolderNavBackArgs) -> Unit, ) { - LaunchedEffect(viewModel.folderNameState.folderId) { if (viewModel.folderNameState.folderId != null) { - resultNavigator.navigateBack( + onFolderCreated( NewConversationFolderNavBackArgs( viewModel.textState.text.toString(), viewModel.folderNameState.folderId!! @@ -88,7 +79,7 @@ fun NewConversationFolderScreen( onContinuePressed = { viewModel.createFolder(viewModel.textState.text.toString()) }, - onBackPressed = navigator::navigateBack + onBackPressed = onBackPressed, ) SnackBarMessageHandler(viewModel.infoMessage) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt index cabcd7ace28..b1662d9a97c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt @@ -21,7 +21,6 @@ package com.wire.android.ui.home.conversations.info import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.R @@ -30,7 +29,6 @@ import com.wire.android.di.CurrentAccount import com.wire.android.model.ImageAsset import com.wire.android.ui.common.R as commonR import com.wire.android.ui.home.conversations.ConversationNavArgs -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.ui.UIText import com.wire.android.util.ui.toUIText import com.wire.kalium.common.error.StorageFailure @@ -50,19 +48,19 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList", "TooManyFunctions") class ConversationInfoViewModel @AssistedInject constructor( private val qualifiedIdMapper: QualifiedIdMapper, - @Assisted val savedStateHandle: SavedStateHandle, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val fetchConversationMLSVerificationStatus: FetchConversationMLSVerificationStatusUseCase, private val isWireCellFeatureEnabled: IsWireCellsEnabledUseCase, @CurrentAccount private val selfUserId: UserId, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ConversationInfoViewModel + fun create(navigationArgs: ConversationNavArgs): ConversationInfoViewModel } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId var conversationInfoViewState by mutableStateOf(ConversationInfoViewState(conversationId)) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt index 9611d0e004f..9f8747e9b1b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationAssetMessagesViewModel.kt @@ -21,13 +21,10 @@ package com.wire.android.ui.home.conversations.media import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.usecase.GetAssetMessagesFromConversationUseCase import com.wire.android.ui.home.conversations.usecase.ObserveImageAssetMessagesFromConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.feature.asset.ObserveAssetStatusesUseCase import dev.zacsweers.metro.Assisted @@ -38,7 +35,7 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList", "TooManyFunctions") class ConversationAssetMessagesViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: ConversationMediaNavArgs, private val getImageMessages: ObserveImageAssetMessagesFromConversationUseCase, private val getAssetMessages: GetAssetMessagesFromConversationUseCase, private val observeAssetStatuses: ObserveAssetStatusesUseCase, @@ -46,11 +43,10 @@ class ConversationAssetMessagesViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ConversationAssetMessagesViewModel + fun create(navigationArgs: ConversationMediaNavArgs): ConversationAssetMessagesViewModel } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() - val conversationId: QualifiedID = conversationNavArgs.conversationId + val conversationId: QualifiedID = navigationArgs.conversationId var viewState by mutableStateOf(ConversationAssetMessagesViewState()) private set diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 9cfc58678d9..7f55caaf3a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.conversations.media -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration @@ -44,9 +43,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.R as commonR -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.TabItem import com.wire.android.ui.common.WireTabRow import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent @@ -62,8 +58,6 @@ import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.DownloadedAssetDialog import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState @@ -73,28 +67,24 @@ import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState import com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel -import com.wire.android.ui.sharing.ImportMediaNavArgs -import com.wire.android.ui.sharing.ImportSource import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions -import com.wire.android.util.fileShareUri import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText import com.wire.android.util.openDownloadFolder +import com.wire.android.util.fileShareUri import com.wire.kalium.logic.data.id.ConversationId import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -@WireRootDestination( - navArgs = ConversationMediaNavArgs::class, - style = PopUpNavigationAnimation::class -) @Composable -fun ConversationMediaScreen( - navigator: Navigator, - conversationAssetMessagesViewModel: ConversationAssetMessagesViewModel = conversationAssetMessagesViewModel(), - conversationMessagesViewModel: ConversationMessagesViewModel = conversationMessagesViewModel() +internal fun ConversationMediaRouteScreen( + conversationAssetMessagesViewModel: ConversationAssetMessagesViewModel, + conversationMessagesViewModel: ConversationMessagesViewModel, + onNavigateBack: () -> Unit, + onShareAssetViaWire: (android.net.Uri) -> Unit, + onOpenGallery: (ConversationId, String, Boolean, String?) -> Unit, ) { val permissionPermanentlyDeniedDialogState = rememberVisibilityState() val context = LocalContext.current @@ -106,21 +96,8 @@ fun ConversationMediaScreen( Content( state = state, - onNavigationPressed = { navigator.navigateBack() }, - onImageFullScreenMode = { conversationId, messageId, isSelfAsset, cellAssetId -> - navigator.navigate( - NavigationCommand( - MediaGalleryScreenDestination( - conversationId = conversationId, - messageId = messageId, - isSelfAsset = isSelfAsset, - isEphemeral = false, - messageOptionsEnabled = false, - cellAssetId = cellAssetId, - ) - ) - ) - }, + onNavigationPressed = onNavigateBack, + onImageFullScreenMode = onOpenGallery, onAssetItemClicked = conversationMessagesViewModel::openOrFetchAsset, onOpenAssetOptions = remember { onOpenAssetOptions }, ) @@ -131,19 +108,10 @@ fun ConversationMediaScreen( conversationMessagesViewModel.deleteMessageDialogState .show(DeleteMessageDialogState(deleteForEveryone, messageId, conversationMessagesViewModel.conversationId)) }, - shareAssetExternally = { conversationMessagesViewModel.shareAsset(context, it) }, + shareAssetExternally = remember { { conversationMessagesViewModel.shareAsset(context, it) } }, shareAssetViaWire = { messageId -> conversationMessagesViewModel.prepareAssetForWireShare(messageId) { path, assetName -> - navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination( - ImportMediaNavArgs( - source = ImportSource.INTERNAL_SHARE, - internalAssetUriList = arrayListOf(context.fileShareUri(path, assetName)) - ) - ) - ) - ) + onShareAssetViaWire(context.fileShareUri(path, assetName)) } }, downloadAsset = conversationMessagesViewModel::openOrFetchAsset, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt index 04cf35d3dba..d656e59cf00 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewScreen.kt @@ -20,7 +20,6 @@ package com.wire.android.ui.home.conversations.media.preview import com.wire.android.ui.home.conversations.checkAssetRestrictionsViewModel import com.wire.android.ui.home.conversations.imagesPreviewViewModel -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.LocalOverscrollConfiguration @@ -52,10 +51,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme @@ -83,28 +79,23 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch import okio.Path.Companion.toPath -@WireRootDestination( - navArgs = ImagesPreviewNavArgs::class, - style = PopUpNavigationAnimation::class -) @Composable -fun ImagesPreviewScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - imagesPreviewViewModel: ImagesPreviewViewModel = imagesPreviewViewModel(), - checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = checkAssetRestrictionsViewModel() +internal fun ImagesPreviewRouteScreen( + imagesPreviewViewModel: ImagesPreviewViewModel, + checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel, + onNavigateBack: () -> Unit, + onCompleted: (List) -> Unit, ) { LaunchedEffect(checkAssetRestrictionsViewModel.state) { with(checkAssetRestrictionsViewModel.state) { if (this is RestrictionCheckState.Success) { - resultNavigator.setResult(ImagesPreviewNavBackArgs(this.assetBundleList)) - resultNavigator.navigateBack() + onCompleted(this.assetBundleList) } } } Content( previewState = imagesPreviewViewModel.viewState, - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onNavigateBack, onSendMessages = { mediaAssets -> checkAssetRestrictionsViewModel.checkRestrictions(importedMediaList = mediaAssets) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt index 8de0f048c43..f8cd0d8e6a6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/preview/ImagesPreviewViewModel.kt @@ -21,11 +21,9 @@ import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.sharing.ImportedMediaAsset import com.wire.android.util.dispatchers.DispatcherProvider import dev.zacsweers.metro.Assisted @@ -36,21 +34,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ImagesPreviewViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navigationArgs: ImagesPreviewNavArgs, private val handleUriAsset: HandleUriAssetUseCase, private val dispatchers: DispatcherProvider ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ImagesPreviewViewModel + fun create(navigationArgs: ImagesPreviewNavArgs): ImagesPreviewViewModel } - private val navArgs: ImagesPreviewNavArgs = savedStateHandle.navArgs() var viewState by mutableStateOf( ImagesPreviewState( - conversationId = navArgs.conversationId, - conversationName = navArgs.conversationName + conversationId = navigationArgs.conversationId, + conversationName = navigationArgs.conversationName ) ) private set @@ -70,7 +67,7 @@ class ImagesPreviewViewModel @AssistedInject constructor( private fun handleAssets() { viewState = viewState.copy(isLoading = true) viewModelScope.launch { - val assets = navArgs.assetUriList.map { handleImportedAsset(it) } + val assets = navigationArgs.assetUriList.map { handleImportedAsset(it) } viewState = viewState.copy( assetBundleList = assets.filterNotNull().toPersistentList(), isLoading = false diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt index a651f02a67c..a9d92c04d6f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.conversations.messagedetails -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration @@ -45,9 +44,6 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.ui.home.conversations.messageDetailsViewModel -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.TabItem import com.wire.android.ui.common.WireTabRow import com.wire.android.ui.common.calculateCurrentTab @@ -62,14 +58,10 @@ import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.UIText import kotlinx.coroutines.launch -@WireRootDestination( - navArgs = MessageDetailsNavArgs::class, - style = PopUpNavigationAnimation::class, -) @Composable -fun MessageDetailsScreen( - navigator: Navigator, - viewModel: MessageDetailsViewModel = messageDetailsViewModel() +internal fun MessageDetailsRouteScreen( + viewModel: MessageDetailsViewModel, + onNavigateBack: () -> Unit, ) { val context = LocalContext.current @@ -95,7 +87,7 @@ fun MessageDetailsScreen( MessageDetailsScreenContent( messageDetailsState = viewModel.messageDetailsState, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, onReactionsLearnMore = onReactionsLearnMore, onReadReceiptsLearnMore = onReadReceiptsLearnMore ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsViewModel.kt index efd3734b8d2..6e5306e5659 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsViewModel.kt @@ -21,33 +21,29 @@ package com.wire.android.ui.home.conversations.messagedetails import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.conversations.messagedetails.usecase.ObserveReactionsForMessageUseCase import com.wire.android.ui.home.conversations.messagedetails.usecase.ObserveReceiptsForMessageUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.message.receipt.ReceiptType -import kotlinx.coroutines.launch import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject - +import kotlinx.coroutines.launch class MessageDetailsViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: MessageDetailsNavArgs, private val observeReactionsForMessage: ObserveReactionsForMessageUseCase, private val observeReceiptsForMessage: ObserveReceiptsForMessageUseCase ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): MessageDetailsViewModel + fun create(navigationArgs: MessageDetailsNavArgs): MessageDetailsViewModel } - private val messageDetailsNavArgs: MessageDetailsNavArgs = savedStateHandle.navArgs() - private val conversationId: QualifiedID = messageDetailsNavArgs.conversationId - private val messageId: String = messageDetailsNavArgs.messageId - private val isSelfMessage: Boolean = messageDetailsNavArgs.isSelfMessage + private val conversationId: QualifiedID = navigationArgs.conversationId + private val messageId: String = navigationArgs.messageId + private val isSelfMessage: Boolean = navigationArgs.isSelfMessage var messageDetailsState: MessageDetailsState by mutableStateOf(MessageDetailsState()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index fd64d0db8ff..b81cff7a352 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -22,11 +22,9 @@ import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.appLogger @@ -98,7 +96,7 @@ import kotlin.time.Duration.Companion.seconds @Suppress("LongParameterList", "TooManyFunctions") class ConversationMessagesViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: ConversationNavArgs, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val getMessageAsset: GetMessageAssetUseCase, private val getMessageByIdUseCase: GetMessageByIdUseCase, @@ -121,10 +119,10 @@ class ConversationMessagesViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ConversationMessagesViewModel + fun create(navigationArgs: ConversationNavArgs): ConversationMessagesViewModel } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs: ConversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId private val searchedMessageIdNavArgs: String? = conversationNavArgs.searchedMessageId diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt index 26f6a52c67d..ba8ae09cc6b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/QuotedMessage.kt @@ -115,6 +115,7 @@ enum class QuotedStyle { PREVIEW } +@Suppress("SlotReused") @Composable internal fun QuotedMessage( conversationId: ConversationId, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt index 0b9b3cf167d..a483a8424f3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.conversations.messages.draft import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -28,7 +27,6 @@ import com.wire.android.ui.home.conversations.usecase.GetQuoteMessageForConversa import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.model.toDraft import com.wire.android.ui.home.messagecomposer.model.update -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.EMPTY import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.message.draft.MessageDraft @@ -40,18 +38,18 @@ import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.launch class MessageDraftViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, private val getMessageDraft: GetMessageDraftUseCase, private val getQuotedMessage: GetQuoteMessageForConversationUseCase, private val saveMessageDraft: SaveMessageDraftUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): MessageDraftViewModel + fun create(navigationArgs: ConversationNavArgs): MessageDraftViewModel } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId var state = mutableStateOf(MessageComposition(conversationId, String.EMPTY)) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index e84307f9f7a..3424cf190c9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -272,6 +272,7 @@ private fun MessageContent( } if (!shouldHideMessageBody) { MessageBody( + conversationId = message.conversationId, messageBody = messageContent.messageBody, searchQuery = searchQuery, isAvailable = !message.isPending && message.isAvailable, @@ -316,6 +317,7 @@ private fun MessageContent( VerticalSpace.x4() } MessageBody( + conversationId = message.conversationId, messageBody = messageContent.messageBody, isAvailable = !message.isPending && message.isAvailable, onOpenProfile = onOpenProfile, @@ -438,6 +440,7 @@ private fun MessageContent( } if (messageContent.messageBody?.message?.asString()?.isNotEmpty() == true) { MessageBody( + conversationId = message.conversationId, messageBody = messageContent.messageBody, searchQuery = searchQuery, isAvailable = !message.isPending && message.isAvailable, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModel.kt index 1ad47f5657b..183004eade1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModel.kt @@ -20,11 +20,9 @@ package com.wire.android.ui.home.conversations.migration import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.conversations.ConversationNavArgs -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID @@ -38,13 +36,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class ConversationMigrationViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, - private val observeConversationDetails: ObserveConversationDetailsUseCase + private val observeConversationDetails: ObserveConversationDetailsUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ConversationMigrationViewModel + fun create(navigationArgs: ConversationNavArgs): ConversationMigrationViewModel } /** @@ -56,7 +54,7 @@ class ConversationMigrationViewModel @AssistedInject constructor( var migratedConversationId by mutableStateOf(null) private set - private val conversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs private val conversationId: QualifiedID = conversationNavArgs.conversationId init { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/CompositeMessageArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/CompositeMessageArgs.kt index 43280b17575..71c45ccd252 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/CompositeMessageArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/CompositeMessageArgs.kt @@ -18,13 +18,15 @@ package com.wire.android.ui.home.conversations.model import com.wire.android.di.ScopedArgs +import com.wire.kalium.logic.data.id.ConversationId import kotlinx.serialization.Serializable @Serializable data class CompositeMessageArgs( + val conversationId: ConversationId, val messageId: String ) : ScopedArgs { - override val key = "$ARGS_KEY:$messageId" + override val key = "$ARGS_KEY:$conversationId:$messageId" companion object { const val ARGS_KEY = "CompositeMessageArgsKey" diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 72d8906b83b..9853d82ca22 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -88,6 +88,7 @@ import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_DOWNLOAD import com.wire.kalium.logic.data.asset.AssetTransferStatus.FAILED_UPLOAD import com.wire.kalium.logic.data.asset.AssetTransferStatus.NOT_FOUND import com.wire.kalium.logic.data.asset.AssetTransferStatus.UPLOAD_IN_PROGRESS +import com.wire.kalium.logic.data.id.ConversationId import kotlinx.collections.immutable.PersistentList import okio.Path @@ -102,6 +103,7 @@ internal fun MessageBody( onOpenProfile: (senderId: MessageSenderId) -> Unit, buttonList: PersistentList?, onLinkClick: (String) -> Unit, + conversationId: ConversationId? = null, searchQuery: String = "", clickable: Boolean = true, messageStyle: MessageStyle = MessageStyle.NORMAL, @@ -160,6 +162,7 @@ internal fun MessageBody( buttonList?.also { VerticalSpace.x4() MessageButtonsContent( + conversationId = requireNotNull(conversationId), messageId = messageId, buttonList = it, messageStyle = messageStyle @@ -169,13 +172,14 @@ internal fun MessageBody( @Composable fun MessageButtonsContent( + conversationId: ConversationId, messageId: String, buttonList: List, messageStyle: MessageStyle, modifier: Modifier = Modifier, viewModel: CompositeMessageViewModel = compositeMessageViewModel( - CompositeMessageArgs(messageId) + CompositeMessageArgs(conversationId, messageId) ) ) { Column( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt index 8ae03d66173..4e17decdbd0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt @@ -43,9 +43,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.model.ItemActionType -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.SearchBarInput import com.wire.android.ui.common.button.WireButtonState @@ -55,7 +52,6 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.wire.android.ui.home.conversations.promoteAdminViewModel import com.wire.android.ui.home.conversations.search.InternalContactSearchResultItem import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme @@ -67,14 +63,10 @@ import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.launch import com.wire.android.ui.common.R as commonR -@WireRootDestination( - navArgs = PromoteAdminNavArgs::class, - style = PopUpNavigationAnimation::class, -) @Composable -fun PromoteAdminScreen( - navigator: Navigator, - viewModel: PromoteAdminViewModel = promoteAdminViewModel(), +internal fun PromoteAdminRouteScreen( + viewModel: PromoteAdminViewModel, + onNavigateBack: () -> Unit, ) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = LocalSnackbarHostState.current @@ -87,12 +79,12 @@ fun PromoteAdminScreen( onSearchQueryChanged = viewModel::onSearchQueryChanged, onUserSelected = viewModel::onUserSelected, onPromoteAdminAndLeave = viewModel::onPromoteAdminAndLeave, - onClose = navigator::navigateBack, + onClose = onNavigateBack, ) HandleActions(viewModel.actions) { action -> when (action) { - PromoteAdminAction.Success -> navigator.navigateBack() + PromoteAdminAction.Success -> onNavigateBack() PromoteAdminAction.FailedToPromoteUser -> coroutineScope.launch { snackbarHostState.showSnackbar(failedToPromoteMessage) } PromoteAdminAction.FailedToLeaveConversation -> diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt index 282f7817449..6838567dd1e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt @@ -17,9 +17,7 @@ */ package com.wire.android.ui.home.conversations.promoteadmin -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.model.UserAvatarData import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.home.conversations.avatar @@ -46,15 +44,13 @@ class PromoteAdminViewModel @AssistedInject constructor( private val observeEligibleMembers: ObserveEligibleMembersForConversationAdminRoleUseCase, private val observeConversationMembers: ObserveConversationMembersUseCase, private val dispatchers: DispatcherProvider, - @Assisted savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: PromoteAdminNavArgs, ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): PromoteAdminViewModel + fun create(navArgs: PromoteAdminNavArgs): PromoteAdminViewModel } - private val navArgs: PromoteAdminNavArgs = savedStateHandle.navArgs() - private val clientEligibleMembers = MutableStateFlow>(emptyList()) private val allConversationMembers = MutableStateFlow>(emptyList()) private val eligibleMemberIds = MutableStateFlow(navArgs.eligibleMembers.map { it.toUserId() }.toSet()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt index 2ea9f9abd9c..9310b2eb0d0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt @@ -17,70 +17,49 @@ */ package com.wire.android.ui.home.conversations.search.adddembertoconversation -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination import com.wire.android.model.Contact import com.wire.android.model.ItemActionType import com.wire.android.search.SearchUsersAndAppsScreen import com.wire.android.ui.common.topappbar.NavigationIconType -import com.wire.android.ui.home.conversations.addMembersToConversationViewModel import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs import com.wire.android.ui.home.newconversation.common.ContinueButton -import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs -import com.wire.kalium.logic.data.id.QualifiedID -import com.wire.kalium.logic.data.user.BotService -import com.wire.kalium.logic.data.user.UserId -@WireRootDestination( - navArgs = AddMembersSearchNavArgs::class -) @Composable -fun AddMembersSearchScreen( - navigator: Navigator, +internal fun AddMembersSearchRouteScreen( + viewModel: AddMembersToConversationViewModel, navArgs: AddMembersSearchNavArgs, - addMembersToConversationViewModel: AddMembersToConversationViewModel = addMembersToConversationViewModel(), + onNavigateBack: () -> Unit, + onOpenUserProfile: (Contact) -> Unit, + onOpenService: (Contact) -> Unit, ) { - if (addMembersToConversationViewModel.newGroupState.isCompleted) { - navigator.navigateBack() + LaunchedEffect(viewModel.newGroupState.isCompleted) { + if (viewModel.newGroupState.isCompleted) { + onNavigateBack() + } } SearchUsersAndAppsScreen( searchTitle = stringResource(id = R.string.label_add_participants), - onOpenUserProfile = { contact: Contact -> - OtherUserProfileScreenDestination(QualifiedID(contact.id, contact.domain)) - .let { navigator.navigate(NavigationCommand(it)) } - }, - onContactChecked = addMembersToConversationViewModel::updateSelectedContacts, - onClose = navigator::navigateBack, - onAppClicked = { contact: Contact -> - val serviceId = when (navArgs.shouldUseNewAppsUi) { - true -> ServiceDetailsNavArgs.Id.AppId(UserId(contact.id, contact.domain)) - false -> ServiceDetailsNavArgs.Id.BotServiceId(BotService(contact.id, contact.domain)) - } - - ServiceDetailsScreenDestination( - navArgs.conversationId, - serviceId - ).let { navigator.navigate(NavigationCommand(it)) } - }, + onOpenUserProfile = onOpenUserProfile, + onContactChecked = viewModel::updateSelectedContacts, + onClose = onNavigateBack, + onAppClicked = onOpenService, navigationIconType = NavigationIconType.Close(R.string.content_description_add_participants_close), itemActionType = ItemActionType.CHECK, - selectedContacts = addMembersToConversationViewModel.newGroupState.selectedContacts, + selectedContacts = viewModel.newGroupState.selectedContacts, isAppsTabVisible = navArgs.isSelfPartOfATeam, isConversationAppsEnabled = navArgs.isConversationAppsEnabled, shouldHideBottomActionForServices = true, conversationProtocol = navArgs.protocolInfo, peopleBottomActions = { focusRequester -> ContinueButton( - onContinue = addMembersToConversationViewModel::addMembersToConversation, + onContinue = viewModel::addMembersToConversation, buttonModifier = Modifier.focusRequester(focusRequester), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt index fec8087b66d..b4fd8e4c154 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt @@ -21,12 +21,10 @@ package com.wire.android.ui.home.conversations.search.adddembertoconversation import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs import com.wire.android.model.Contact -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.AddMemberToConversationUseCase @@ -42,15 +40,13 @@ import kotlinx.coroutines.withContext class AddMembersToConversationViewModel @AssistedInject constructor( private val addMemberToConversation: AddMemberToConversationUseCase, private val dispatchers: DispatcherProvider, - @Assisted savedStateHandle: SavedStateHandle + @Assisted private val addMembersSearchNavArgs: AddMembersSearchNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): AddMembersToConversationViewModel + fun create(addMembersSearchNavArgs: AddMembersSearchNavArgs): AddMembersToConversationViewModel } - private val addMembersSearchNavArgs: AddMembersSearchNavArgs = savedStateHandle.navArgs() - var newGroupState: AddMembersToConversationState by mutableStateOf(AddMembersToConversationState()) private set diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt index 1e4ae444d4c..f6e6456c45e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt @@ -29,22 +29,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.paging.compose.collectAsLazyPagingItems -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.R -import com.wire.android.feature.cells.ui.search.SearchNavArgs -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.search.SearchTopBar -import com.wire.android.ui.home.conversations.ConversationNavArgs -import com.wire.android.ui.home.conversations.searchConversationMessagesViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -52,42 +42,20 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId import com.wire.android.ui.common.R as commonR -@WireRootDestination( - navArgs = SearchConversationMessagesNavArgs::class, - style = PopUpNavigationAnimation::class -) @Composable -fun SearchConversationMessagesScreen( - navigator: Navigator, - searchConversationMessagesViewModel: SearchConversationMessagesViewModel = searchConversationMessagesViewModel() +internal fun SearchConversationMessagesRouteScreen( + viewModel: SearchConversationMessagesViewModel, + onMessageClick: (String) -> Unit, + onCloseSearchClicked: () -> Unit, + onSearchFilesButtonClick: () -> Unit, ) { SearchConversationMessagesResultContent( - isCellsConversation = searchConversationMessagesViewModel.isCellsConversation, - searchQueryTextState = searchConversationMessagesViewModel.searchQueryTextState, - state = searchConversationMessagesViewModel.searchConversationMessagesState, - onMessageClick = { messageId -> - navigator.navigate( - NavigationCommand( - ConversationScreenDestination( - navArgs = ConversationNavArgs( - conversationId = searchConversationMessagesViewModel.searchConversationMessagesState.conversationId, - searchedMessageId = messageId - ) - ), - BackStackMode.UPDATE_EXISTED - ) - ) - }, - onCloseSearchClicked = navigator::navigateBack, - onSearchFilesButtonClick = { - navigator.navigate( - NavigationCommand( - SearchScreenDestination( - SearchNavArgs(conversationId = searchConversationMessagesViewModel.conversationId.toString()) - ) - ) - ) - } + isCellsConversation = viewModel.isCellsConversation, + searchQueryTextState = viewModel.searchQueryTextState, + state = viewModel.searchConversationMessagesState, + onMessageClick = onMessageClick, + onCloseSearchClicked = onCloseSearchClicked, + onSearchFilesButtonClick = onSearchFilesButtonClick, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt index 4983012ceaf..ac6471dabd0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt @@ -21,17 +21,15 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.android.ui.home.conversations.usecase.GetConversationMessagesFromSearchUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.kalium.logic.data.id.QualifiedID import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import com.wire.kalium.logic.data.id.QualifiedID import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest @@ -41,15 +39,13 @@ import kotlinx.coroutines.flow.onEach class SearchConversationMessagesViewModel @AssistedInject constructor( private val getSearchMessagesForConversation: GetConversationMessagesFromSearchUseCase, private val dispatchers: DispatcherProvider, - @Assisted savedStateHandle: SavedStateHandle + @Assisted searchConversationMessagesNavArgs: SearchConversationMessagesNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): SearchConversationMessagesViewModel + fun create(searchConversationMessagesNavArgs: SearchConversationMessagesNavArgs): SearchConversationMessagesViewModel } - private val searchConversationMessagesNavArgs: SearchConversationMessagesNavArgs = savedStateHandle.navArgs() - val conversationId: QualifiedID = searchConversationMessagesNavArgs.conversationId val groupName: String = searchConversationMessagesNavArgs.groupName val isCellsConversation: Boolean = searchConversationMessagesNavArgs.isCellsConversation diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index 3e2c5260e01..6f9580d12d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -21,10 +21,8 @@ package com.wire.android.ui.home.conversations.sendmessage import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.R import com.wire.android.appLogger import com.wire.android.feature.analytics.AnonymousAnalyticsManager @@ -94,7 +92,6 @@ import kotlinx.coroutines.withContext @Suppress("LongParameterList", "TooManyFunctions") class SendMessageViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, private val sendAssetMessage: ScheduleNewAssetMessageUseCase, private val sendTextMessage: SendTextMessageUseCase, private val sendMultipartMessage: SendMultipartMessageUseCase, @@ -119,14 +116,15 @@ class SendMessageViewModel @AssistedInject constructor( private val sharedState: MessageSharedState, private val generateLinkPreview: GenerateLinkPreviewUseCase, private val detectLinkPreviewTarget: DetectLinkPreviewTargetUseCase, + @Assisted navigationArgs: ConversationNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): SendMessageViewModel + fun create(navigationArgs: ConversationNavArgs): SendMessageViewModel } - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + private val conversationNavArgs = navigationArgs val conversationId: QualifiedID = conversationNavArgs.conversationId private val _infoMessage = MutableSharedFlow() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt index b8ed6fa2408..d3a423f5c37 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.di.ScopedArgs import com.wire.android.di.ViewModelScopedPreview import com.wire.android.ui.home.conversations.usecase.ObserveUsersTypingInConversationUseCase import com.wire.kalium.logic.data.id.QualifiedID @@ -64,8 +63,8 @@ class TypingIndicatorViewModelImpl @AssistedInject constructor( } @Serializable -data class TypingIndicatorArgs(val conversationId: QualifiedID) : ScopedArgs { - override val key = "$ARGS_KEY:$conversationId" +data class TypingIndicatorArgs(val conversationId: QualifiedID) { + val key = "$ARGS_KEY:$conversationId" companion object { const val ARGS_KEY = "TypingIndicatorArgsKey" diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt deleted file mode 100644 index f23756ff663..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.home.conversations.videoplayer - -import androidx.compose.runtime.Composable -import com.wire.android.mediaplayer.VideoPlayer -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation - -/** - * App navigation entry point for the shared [VideoPlayer]. Lets chat (and any app screen) play a - * downloaded video in-app instead of handing it off to an external application. - */ -@WireRootDestination( - navArgs = VideoPlayerNavArgs::class, - style = PopUpNavigationAnimation::class, -) -@Composable -fun VideoPlayerScreen( - navigator: Navigator, - navArgs: VideoPlayerNavArgs, -) { - VideoPlayer( - localPath = navArgs.localPath, - contentUrl = navArgs.contentUrl, - fileName = navArgs.fileName, - onNavigateBack = navigator::navigateBack, - ) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 1d643480a22..8ba130b2580 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -16,6 +16,8 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("MatchingDeclarationName") + package com.wire.android.ui.home.conversationslist import androidx.compose.foundation.layout.Box @@ -35,21 +37,11 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems -import com.ramcosta.composedestinations.generated.app.destinations.BrowseChannelsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DebugConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination import com.wire.android.R import com.wire.android.ui.common.R as commonR import com.wire.android.appLogger import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl import com.wire.android.feature.analytics.model.AnalyticsEvent -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.calling.conversationListCallViewModel import com.wire.android.ui.calling.ongoing.getOngoingCallIntent import com.wire.android.ui.common.HandleActions @@ -65,6 +57,7 @@ import com.wire.android.ui.common.search.rememberSearchbarState import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState +import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs import com.wire.android.ui.home.conversations.promoteadmin.toPromoteAdminEligibleMemberArgs import com.wire.android.ui.home.conversationListViewModel @@ -80,14 +73,25 @@ import com.wire.android.util.ui.collectAsLazyPagingItemsWithLifecycle import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId +@Suppress("LongParameterList") +data class ConversationsNavigationActions( + val openConversation: (ConversationId) -> Unit, + val openUserProfile: (UserId) -> Unit, + val startConversation: () -> Unit, + val browseChannels: () -> Unit, + val openConversationFolders: (ConversationFoldersNavArgs) -> Unit, + val promoteAdmin: (PromoteAdminNavArgs) -> Unit, + val openDebugMenu: (DebugConversationScreenNavArgs) -> Unit, +) + /** * This is a base for creating screens for displaying list of conversations. * Can be used to create proper navigation destination for different sources of conversations, like archive. */ -@Suppress("ComplexMethod", "NestedBlockDepth", "Wrapping", "SlotReused") +@Suppress("ComplexMethod", "NestedBlockDepth", "Wrapping", "SlotReused", "LongParameterList") @Composable fun ConversationsScreenContent( - navigator: Navigator, + navigationActions: ConversationsNavigationActions, searchBarState: SearchBarState, modifier: Modifier = Modifier, emptyListContent: @Composable (domain: String) -> Unit = {}, @@ -141,27 +145,20 @@ fun ConversationsScreenContent( } } - val onOpenConversation: (ConversationItem) -> Unit = remember(navigator, onConversationOpened) { + val onOpenConversation: (ConversationItem) -> Unit = remember(navigationActions, onConversationOpened) { { - navigator.navigate(NavigationCommand(ConversationScreenDestination(it.conversationId))) + navigationActions.openConversation(it.conversationId) onConversationOpened() } } - val onOpenUserProfile: (UserId) -> Unit = remember(navigator) { - { - navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(it))) - } - } + val onOpenUserProfile: (UserId) -> Unit = remember(navigationActions) { navigationActions.openUserProfile } val onJoinCall: (ConversationId) -> Unit = remember { { conversationListCallViewModel.joinOngoingCall(it) } } - val onNewConversationClicked: () -> Unit = remember { - { - navigator.navigate(NavigationCommand(NewConversationSearchPeopleScreenDestination)) - } - } + val onNewConversationClicked: () -> Unit = + remember(navigationActions) { navigationActions.startConversation } val onPlayPauseCurrentAudio: () -> Unit = remember { { @@ -209,7 +206,7 @@ fun ConversationsScreenContent( onPlayPauseCurrentAudio = onPlayPauseCurrentAudio, onStopCurrentAudio = onStopCurrentAudio, onBrowsePublicChannels = { - navigator.navigate(NavigationCommand(BrowseChannelsScreenDestination)) + navigationActions.browseChannels() } ) // when there is no conversation in any folder @@ -276,24 +273,14 @@ fun ConversationsScreenContent( ConversationOptionsModalSheetLayout( sheetState = sheetState, - openConversationFolders = { navigator.navigate(NavigationCommand(ConversationFoldersScreenDestination(it))) }, + openConversationFolders = navigationActions.openConversationFolders, onPromoteAdmin = { conversationId, eligibleMembers -> - navigator.navigate( - NavigationCommand( - PromoteAdminScreenDestination( - PromoteAdminNavArgs(conversationId, eligibleMembers.toPromoteAdminEligibleMemberArgs()) - ) - ) + navigationActions.promoteAdmin( + PromoteAdminNavArgs(conversationId, eligibleMembers.toPromoteAdminEligibleMemberArgs()) ) }, openConversationDebugMenu = { conversationId -> - navigator.navigate( - NavigationCommand( - DebugConversationScreenDestination( - navArgs = DebugConversationScreenNavArgs(conversationId) - ) - ) - ) + navigationActions.openDebugMenu(DebugConversationScreenNavArgs(conversationId)) }, ) @@ -316,5 +303,16 @@ private const val TAG = "BaseConversationsScreen" @PreviewMultipleThemes @Composable fun PreviewConversationsScreenContent() = WireTheme { - ConversationsScreenContent(navigator = rememberNavigator { }, searchBarState = rememberSearchbarState()) + ConversationsScreenContent( + navigationActions = ConversationsNavigationActions( + openConversation = {}, + openUserProfile = {}, + startConversation = {}, + browseChannels = {}, + openConversationFolders = {}, + promoteAdmin = {}, + openDebugMenu = {}, + ), + searchBarState = rememberSearchbarState(), + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index b296aaf6ee8..4eab3ff5272 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -18,20 +18,19 @@ package com.wire.android.ui.home.conversationslist.all -import com.wire.android.navigation.annotation.app.WireHomeDestination import androidx.compose.animation.Crossfade import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.search.rememberSearchbarState -import com.wire.android.ui.home.HomeStateHolder +import com.wire.android.ui.home.HomeShellState import com.wire.android.ui.home.conversations.conversationFoldersViewModel import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs import com.wire.android.ui.home.conversations.folder.ConversationFoldersVM import com.wire.android.ui.home.conversationslist.ConversationListViewModelPreview import com.wire.android.ui.home.conversationslist.ConversationsScreenContent +import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions import com.wire.android.ui.home.conversationslist.common.previewConversationItemsFlow import com.wire.android.ui.home.conversationslist.filter.ConversationFilterSheetContent import com.wire.android.ui.home.conversationslist.model.ConversationsSource @@ -39,20 +38,20 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.conversation.ConversationFilter -@WireHomeDestination(start = true) @Composable -fun AllConversationsScreen( - homeStateHolder: HomeStateHolder, +internal fun AllConversationsContent( + homeShellState: HomeShellState, + navigationActions: ConversationsNavigationActions, foldersViewModel: ConversationFoldersVM = conversationFoldersViewModel(ConversationFoldersStateArgs(null)), ) { - with(homeStateHolder) { + with(homeShellState) { Crossfade( - targetState = homeStateHolder.currentConversationFilter, + targetState = homeShellState.currentConversationFilter, label = "Conversation filter change animation", ) { filter -> ConversationsScreenContent( - navigator = navigator, + navigationActions = navigationActions, searchBarState = searchBarState, conversationsSource = when (filter) { is ConversationFilter.All -> ConversationsSource.MAIN @@ -65,19 +64,24 @@ fun AllConversationsScreen( lazyListState = lazyListStateFor(HomeDestination.Conversations, filter), emptySearchResultFocusRequester = emptySearchResultFocusRequester, firstConversationFocusRequester = firstConversationFocusRequester, - onConversationOpened = homeStateHolder::requestClearSearchOnNextResume, - emptyListContent = { ConversationsEmptyContent(filter = filter, navigator = navigator) } + onConversationOpened = homeShellState::requestClearSearchOnNextResume, + emptyListContent = { + ConversationsEmptyContent( + filter = filter, + onBrowseChannels = navigationActions.browseChannels, + ) + } ) } WireModalSheetLayout( sheetState = conversationsFilterBottomSheetState, sheetContent = { sheetData -> ConversationFilterSheetContent( - currentFilter = homeStateHolder.currentConversationFilter, + currentFilter = homeShellState.currentConversationFilter, folders = foldersViewModel.state().folders, onChangeFilter = { filter -> conversationsFilterBottomSheetState.hide() - homeStateHolder.changeConversationFilter(filter) + homeShellState.changeConversationFilter(filter) }, ) } @@ -89,10 +93,10 @@ fun AllConversationsScreen( @Composable fun PreviewAllConversationsEmptyScreen() = WireTheme { ConversationsScreenContent( - navigator = rememberNavigator {}, + navigationActions = previewConversationsNavigationActions(), searchBarState = rememberSearchbarState(), conversationsSource = ConversationsSource.MAIN, - emptyListContent = { ConversationsEmptyContent(navigator = rememberNavigator {}) }, + emptyListContent = { ConversationsEmptyContent(onBrowseChannels = {}) }, conversationListViewModel = ConversationListViewModelPreview(previewConversationItemsFlow(list = listOf())), ) } @@ -101,10 +105,10 @@ fun PreviewAllConversationsEmptyScreen() = WireTheme { @Composable fun PreviewAllConversationsEmptySearchScreen() = WireTheme { ConversationsScreenContent( - navigator = rememberNavigator {}, + navigationActions = previewConversationsNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.MAIN, - emptyListContent = { ConversationsEmptyContent(navigator = rememberNavigator {}) }, + emptyListContent = { ConversationsEmptyContent(onBrowseChannels = {}) }, conversationListViewModel = ConversationListViewModelPreview(previewConversationItemsFlow(searchQuery = "er", list = listOf())), ) } @@ -113,10 +117,20 @@ fun PreviewAllConversationsEmptySearchScreen() = WireTheme { @Composable fun PreviewAllConversationsSearchScreen() = WireTheme { ConversationsScreenContent( - navigator = rememberNavigator {}, + navigationActions = previewConversationsNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.MAIN, - emptyListContent = { ConversationsEmptyContent(navigator = rememberNavigator {}) }, + emptyListContent = { ConversationsEmptyContent(onBrowseChannels = {}) }, conversationListViewModel = ConversationListViewModelPreview(previewConversationItemsFlow("er")), ) } + +private fun previewConversationsNavigationActions() = ConversationsNavigationActions( + openConversation = {}, + openUserProfile = {}, + startConversation = {}, + browseChannels = {}, + openConversationFolders = {}, + promoteAdmin = {}, + openDebugMenu = {}, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt index 70f543a30f7..de01544e895 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/ConversationsEmptyContent.kt @@ -29,16 +29,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import com.wire.android.BuildConfig import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.rowitem.EmptyListArrowFooter import com.wire.android.ui.common.rowitem.EmptyListContent import com.wire.android.ui.common.spacers.VerticalSpace -import com.ramcosta.composedestinations.generated.app.destinations.BrowseChannelsScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper @@ -49,7 +45,7 @@ import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable fun ConversationsEmptyContent( - navigator: Navigator, + onBrowseChannels: () -> Unit, modifier: Modifier = Modifier, filter: ConversationFilter = ConversationFilter.All, domain: String = "wire.com" @@ -59,13 +55,13 @@ fun ConversationsEmptyContent( text = filter.emptyDescription(domain), modifier = modifier, footer = { - EmptyContentFooter(currentFilter = filter, navigator = navigator) + EmptyContentFooter(currentFilter = filter, onBrowseChannels = onBrowseChannels) } ) } @Composable -private fun EmptyContentFooter(currentFilter: ConversationFilter, navigator: Navigator) { +private fun EmptyContentFooter(currentFilter: ConversationFilter, onBrowseChannels: () -> Unit) { val context = LocalContext.current when (currentFilter) { ConversationFilter.Favorites -> { @@ -106,7 +102,7 @@ private fun EmptyContentFooter(currentFilter: ConversationFilter, navigator: Nav } else { WireButtonState.Disabled }, - onClick = { navigator.navigate(NavigationCommand(BrowseChannelsScreenDestination)) } + onClick = onBrowseChannels ) } @@ -128,29 +124,33 @@ private fun ConversationFilter.emptyDescription(backendName: String): String = w @PreviewMultipleThemes @Composable fun PreviewAllConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.All, navigator = rememberNavigator {}) + ConversationsEmptyContent(filter = ConversationFilter.All, onBrowseChannels = {}) } @PreviewMultipleThemes @Composable fun PreviewChannelsConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.Channels, navigator = rememberNavigator {}) + ConversationsEmptyContent(filter = ConversationFilter.Channels, onBrowseChannels = {}) } @PreviewMultipleThemes @Composable fun PreviewFavoritesConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.Favorites, navigator = rememberNavigator {}) + ConversationsEmptyContent(filter = ConversationFilter.Favorites, onBrowseChannels = {}) } @PreviewMultipleThemes @Composable fun PreviewGroupConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.Groups, navigator = rememberNavigator {}) + ConversationsEmptyContent(filter = ConversationFilter.Groups, onBrowseChannels = {}) } @PreviewMultipleThemes @Composable fun PreviewOneOnOneConversationsEmptyContent() = WireTheme { - ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = "wire.com", navigator = rememberNavigator {}) + ConversationsEmptyContent( + filter = ConversationFilter.OneOnOne, + domain = "wire.com", + onBrowseChannels = {}, + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt index 0e7572e574f..2b902e8b18e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawer.kt @@ -53,9 +53,6 @@ import androidx.compose.ui.text.style.TextAlign import com.wire.android.R import com.wire.android.ui.common.R as commonR import com.wire.android.model.Clickable -import com.wire.android.navigation.ExternalDirectionLess -import com.wire.android.navigation.ExternalUriDirection -import com.wire.android.navigation.ExternalUriStringResDirection import com.wire.android.navigation.HomeDestination import com.wire.android.ui.common.Logo import com.wire.android.ui.common.clickable @@ -71,7 +68,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun HomeDrawer( homeDrawerState: HomeDrawerState, - currentRoute: String?, + currentDestination: HomeDestination, navigateToHomeItem: (HomeDestination) -> Unit, onCloseDrawer: () -> Unit, modifier: Modifier = Modifier, @@ -119,7 +116,7 @@ fun HomeDrawer( MapToDrawerItem( navigateToHomeItem = navigateToHomeItem, onCloseDrawer = onCloseDrawer, - currentRoute = currentRoute, + currentDestination = currentDestination, drawerUiItem = item ) } @@ -132,7 +129,7 @@ fun HomeDrawer( MapToDrawerItem( navigateToHomeItem = navigateToHomeItem, onCloseDrawer = onCloseDrawer, - currentRoute = currentRoute, + currentDestination = currentDestination, drawerUiItem = item ) } @@ -144,7 +141,7 @@ fun HomeDrawer( fun MapToDrawerItem( navigateToHomeItem: (HomeDestination) -> Unit, onCloseDrawer: () -> Unit, - currentRoute: String?, + currentDestination: HomeDestination, drawerUiItem: DrawerUiItem, ) { val context = LocalContext.current @@ -157,7 +154,7 @@ fun MapToDrawerItem( when (this) { is DrawerUiItem.DynamicExternalNavigationItem -> DrawerItem( destination = destination, - selected = currentRoute == destination.direction.route, + selected = currentDestination == destination, onItemClick = remember(url, onCloseDrawer) { { com.wire.android.util.CustomTabsHelper.launchUrl(context, url) @@ -168,7 +165,7 @@ fun MapToDrawerItem( is DrawerUiItem.RegularItem -> DrawerItem( destination = destination, - selected = currentRoute == destination.direction.route, + selected = currentDestination == destination, onItemClick = remember(destination, navigateToHomeItem, onCloseDrawer) { { navigateAndCloseDrawer(destination) @@ -179,7 +176,7 @@ fun MapToDrawerItem( is DrawerUiItem.UnreadCounterItem -> DrawerItem( destination = destination, unreadCount = this.unreadCount.toInt(), - selected = currentRoute == destination.direction.route, + selected = currentDestination == destination, onItemClick = remember(destination, navigateToHomeItem, onCloseDrawer) { { navigateAndCloseDrawer(destination) @@ -267,16 +264,14 @@ fun DrawerItem( .weight(1F) ) UnreadMessageEventBadge(unreadMessageCount = unreadCount) - with(destination) { - if (direction is ExternalUriDirection || direction is ExternalUriStringResDirection || direction is ExternalDirectionLess) { - HorizontalSpace.x8() - Icon( - painter = painterResource(commonR.drawable.ic_open_in_new), - contentDescription = null, - tint = colorsScheme().secondaryText, - modifier = Modifier.size(dimensions().spacing16x) - ) - } + if (destination == HomeDestination.Support || destination == HomeDestination.TeamManagement) { + HorizontalSpace.x8() + Icon( + painter = painterResource(commonR.drawable.ic_open_in_new), + contentDescription = null, + tint = colorsScheme().secondaryText, + modifier = Modifier.size(dimensions().spacing16x) + ) } HorizontalSpace.x12() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt index 081a086d201..73bf7b2638c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModel.kt @@ -21,7 +21,6 @@ package com.wire.android.ui.home.drawer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.navigation.HomeDestination @@ -32,9 +31,7 @@ import com.wire.kalium.logic.feature.conversation.ObserveArchivedUnreadConversat import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase import com.wire.kalium.logic.feature.user.ObserveIsMeetingsEnabledUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf @@ -42,20 +39,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @Suppress("LongParameterList") -class HomeDrawerViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, +class HomeDrawerViewModel @Inject constructor( private val observeArchivedUnreadConversationsCount: Lazy, private val observeSelfUser: ObserveSelfUserUseCase, private val getTeamUrl: GetTeamUrlUseCase, private val isWireCellsEnabled: IsWireCellsEnabledUseCase, private val observeIsWireMeetingsEnabled: ObserveIsMeetingsEnabledUseCase, ) : ViewModel() { - - @AssistedFactory - interface Factory { - fun create(savedStateHandle: SavedStateHandle): HomeDrawerViewModel - } - var drawerState by mutableStateOf(HomeDrawerState()) private set @@ -78,7 +68,6 @@ class HomeDrawerViewModel @AssistedInject constructor( combine( flowOf(isWireCellsEnabled()), observeIsWireMeetingsEnabled(), - observeArchivedUnreadConversationsCount.value.invoke(), observeTeamManagementUrlForUser(), ) { wireCellsEnabled, wireMeetingsEnabled, unreadArchiveConversationsCount, teamManagementUrl -> diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt index d0649d99109..8de2da0d12d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.gallery -import com.wire.android.navigation.annotation.app.WireRootDestination import android.content.Context import android.widget.Toast import androidx.compose.foundation.background @@ -33,14 +32,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import coil3.annotation.ExperimentalCoilApi -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R import com.wire.android.ui.common.R as commonR -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout @@ -65,11 +58,9 @@ import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogS import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.mediaGalleryViewModel import com.wire.android.ui.home.conversations.mock.mockedPrivateAsset -import com.wire.android.ui.sharing.ImportMediaNavArgs -import com.wire.android.ui.sharing.ImportSource import com.wire.android.ui.theme.WireTheme -import com.wire.android.util.fileShareUri import com.wire.android.util.permission.rememberWriteStoragePermissionFlow +import com.wire.android.util.fileShareUri import com.wire.android.util.startFileShareIntent import com.wire.android.util.supportsTrustedWireShareCaller import com.wire.android.util.ui.PreviewMultipleThemes @@ -77,17 +68,15 @@ import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.openDownloadFolder @OptIn(ExperimentalCoilApi::class) -@WireRootDestination( - navArgs = MediaGalleryNavArgs::class, - style = PopUpNavigationAnimation::class, -) @Composable -fun MediaGalleryScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, +internal fun MediaGalleryRouteScreen( + mediaGalleryViewModel: MediaGalleryViewModel, + onNavigateBack: () -> Unit, + onResult: (MediaGalleryNavBackArgs) -> Unit, + onOpenPublicLink: (String, String, String?) -> Unit, + onShareAssetViaWire: (android.net.Uri) -> Unit, modifier: Modifier = Modifier, ) { - val mediaGalleryViewModel: MediaGalleryViewModel = mediaGalleryViewModel() val permissionPermanentlyDeniedDialogState = rememberVisibilityState() @@ -120,7 +109,7 @@ fun MediaGalleryScreen( MediaGalleryContent( state = viewModelState, - onCloseClick = navigator::navigateBack, + onCloseClick = onNavigateBack, onOptionsClick = mediaGalleryViewModel::onOptionsClick, modifier = modifier, ) @@ -144,66 +133,44 @@ fun MediaGalleryScreen( HandleActions(mediaGalleryViewModel.actions) { action -> when (action) { is MediaGalleryAction.ShareExternally -> context.startFileShareIntent(action.path, action.assetName) - is MediaGalleryAction.ShareViaWire -> navigator.navigate( - NavigationCommand( - ImportMediaScreenDestination( - ImportMediaNavArgs( - source = ImportSource.INTERNAL_SHARE, - internalAssetUriList = arrayListOf(context.fileShareUri(action.path, action.assetName)) - ) - ) - ) - ) - + is MediaGalleryAction.ShareViaWire -> onShareAssetViaWire(context.fileShareUri(action.path, action.assetName)) is MediaGalleryAction.ShowDetails -> { - resultNavigator.setResult( + onResult( MediaGalleryNavBackArgs( messageId = action.messageId, isSelfAsset = action.isSelfAsset, mediaGalleryActionType = MediaGalleryActionType.DETAIL ) ) - resultNavigator.navigateBack() } is MediaGalleryAction.React -> { - resultNavigator.setResult( + onResult( MediaGalleryNavBackArgs( messageId = action.messageId, emoji = action.emoji, mediaGalleryActionType = MediaGalleryActionType.REACT ) ) - resultNavigator.navigateBack() } is MediaGalleryAction.Reply -> { - resultNavigator.setResult( + onResult( MediaGalleryNavBackArgs( messageId = action.messageId, mediaGalleryActionType = MediaGalleryActionType.REPLY ) ) - resultNavigator.navigateBack() } MediaGalleryAction.Download -> { onSaveImageWriteStorageRequest.launch() } is MediaGalleryAction.SharePublicLink -> { - navigator.navigate( - NavigationCommand( - PublicLinkScreenDestination( - assetId = action.assetId, - fileName = action.assetName, - publicLinkId = action.publicLinkId, - isFolder = false, - ) - ) - ) + onOpenPublicLink(action.assetId, action.assetName, action.publicLinkId) mediaGalleryViewModel.onOptionsDismissed() } MediaGalleryAction.ShowError -> showErrorMessage(context) - MediaGalleryAction.Close -> navigator.navigateBack() + MediaGalleryAction.Close -> onNavigateBack() } } } @@ -278,9 +245,7 @@ private fun MediaGalleryOptionsBottomSheetLayout( DownloadAssetExternallyOption { onOptionsClick(MenuIntent.Download) } } MediaGalleryMenuItem.SHARE_EXTERNALLY -> if (!collapseShareOptions) { - add { - ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } - } + add { ShareAssetExternallyMenuOption { onOptionsClick(MenuIntent.ShareExternally) } } } MediaGalleryMenuItem.SHARE_VIA_WIRE -> add { if (collapseShareOptions) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt index f2dd4cea0a3..bef9ecf96d0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModel.kt @@ -21,14 +21,12 @@ package com.wire.android.ui.home.gallery import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.model.ImageAsset import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.home.conversations.MediaGallerySnackbarMessages import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.FileManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase @@ -56,7 +54,7 @@ import okio.Path @Suppress("LongParameterList", "TooManyFunctions") class MediaGalleryViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted private val navigationArgs: MediaGalleryNavArgs, private val getConversationDetails: ObserveConversationDetailsUseCase, private val dispatchers: DispatcherProvider, private val getImageData: GetMessageAssetUseCase, @@ -69,14 +67,12 @@ class MediaGalleryViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): MediaGalleryViewModel + fun create(navigationArgs: MediaGalleryNavArgs): MediaGalleryViewModel } - private val mediaGalleryNavArgs: MediaGalleryNavArgs = savedStateHandle.navArgs() - - private val messageId = mediaGalleryNavArgs.messageId - private val conversationId = mediaGalleryNavArgs.conversationId - private val cellAssetId = mediaGalleryNavArgs.cellAssetId + private val messageId = navigationArgs.messageId + private val conversationId = navigationArgs.conversationId + private val cellAssetId = navigationArgs.cellAssetId var mediaGalleryViewState by mutableStateOf(MediaGalleryViewState()) private set @@ -104,10 +100,10 @@ class MediaGalleryViewModel @AssistedInject constructor( mediaGalleryViewState = mediaGalleryViewState.copy( imageAsset = MediaGalleryImage.PrivateAsset( asset = ImageAsset.PrivateAsset( - mediaGalleryNavArgs.conversationId, - mediaGalleryNavArgs.messageId, - mediaGalleryNavArgs.isSelfAsset, - mediaGalleryNavArgs.isEphemeral + navigationArgs.conversationId, + navigationArgs.messageId, + navigationArgs.isSelfAsset, + navigationArgs.isEphemeral ) ) ) @@ -157,6 +153,14 @@ class MediaGalleryViewModel @AssistedInject constructor( } } + private fun shareAssetViaWire() = viewModelScope.launch { + if (cellAssetId == null) { + assetDataPath(conversationId, messageId)?.run { + sendAction(MediaGalleryAction.ShareViaWire(first, second)) + } + } + } + private suspend fun assetDataPath(conversationId: QualifiedID, messageId: String): Pair? = getImageData(conversationId, messageId).await().run { return when (this) { @@ -206,14 +210,6 @@ class MediaGalleryViewModel @AssistedInject constructor( } } - private fun shareAssetViaWire() = viewModelScope.launch { - if (cellAssetId == null) { - assetDataPath(conversationId, messageId)?.run { - sendAction(MediaGalleryAction.ShareViaWire(first, second)) - } - } - } - private fun onSnackbarMessage(messageCode: MediaGallerySnackbarMessages) { viewModelScope.launch { _snackbarMessage.emit(messageCode) @@ -240,7 +236,7 @@ class MediaGalleryViewModel @AssistedInject constructor( MenuIntent.ShowDetails -> sendAction( MediaGalleryAction.ShowDetails( messageId = messageId, - isSelfAsset = mediaGalleryNavArgs.isSelfAsset + isSelfAsset = navigationArgs.isSelfAsset ) ) @@ -253,11 +249,12 @@ class MediaGalleryViewModel @AssistedInject constructor( MenuIntent.Download -> sendAction(MediaGalleryAction.Download) MenuIntent.ShareExternally -> shareAsset() + MenuIntent.ShareViaWire -> shareAssetViaWire() MenuIntent.Delete -> { deleteMessageDialogState.show( - DeleteMessageDialogState(mediaGalleryNavArgs.isSelfAsset, messageId, conversationId) + DeleteMessageDialogState(navigationArgs.isSelfAsset, messageId, conversationId) ) } } @@ -278,7 +275,7 @@ class MediaGalleryViewModel @AssistedInject constructor( } private fun buildMenuOptions() = buildList { - if (mediaGalleryNavArgs.messageOptionsEnabled) { + if (navigationArgs.messageOptionsEnabled) { when { cellAssetId != null -> { add(MediaGalleryMenuItem.REACT) @@ -289,7 +286,7 @@ class MediaGalleryViewModel @AssistedInject constructor( } } - mediaGalleryNavArgs.isEphemeral -> { + navigationArgs.isEphemeral -> { add(MediaGalleryMenuItem.SHOW_DETAILS) add(MediaGalleryMenuItem.DOWNLOAD) add(MediaGalleryMenuItem.DELETE) @@ -307,7 +304,7 @@ class MediaGalleryViewModel @AssistedInject constructor( } } else if (cellAssetId == null) { add(MediaGalleryMenuItem.DOWNLOAD) - if (!mediaGalleryNavArgs.isEphemeral) { + if (!navigationArgs.isEphemeral) { add(MediaGalleryMenuItem.SHARE_VIA_WIRE) add(MediaGalleryMenuItem.SHARE_EXTERNALLY) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt index 7f2e30fdde8..83a9187a67b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt @@ -20,44 +20,40 @@ package com.wire.android.ui.home.meetings import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingScreenDestination import com.wire.android.feature.meetings.ui.AllMeetingsScreen import com.wire.android.feature.meetings.ui.NewMeetingBottomSheet -import com.wire.android.feature.meetings.ui.create.NewMeetingType import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.annotation.app.WireHomeDestination +import com.wire.android.ui.common.dimensions +import com.wire.android.feature.meetings.ui.create.NewMeetingType import com.wire.android.ui.calling.meetingsCallViewModel import com.wire.android.ui.calling.ongoing.getOngoingCallIntent -import com.wire.android.ui.common.WireDialog -import com.wire.android.ui.common.WireDialogButtonProperties -import com.wire.android.ui.common.WireDialogButtonType -import com.wire.android.ui.common.VisibilityState -import com.wire.android.ui.common.button.WireButtonState -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.visbility.VisibilityState -import com.wire.android.ui.home.HomeStateHolder +import com.wire.android.ui.home.HomeShellState import com.wire.android.ui.home.conversations.call.HandleActions import com.wire.android.ui.home.conversations.call.HandleJoinOrStartCallScreenDialogs -import com.wire.android.feature.meetings.R as meetingsR -import com.wire.android.ui.common.R as commonR +import com.wire.kalium.logic.data.conversation.Conversation -@WireHomeDestination +/** + * Navigation-neutral Meetings renderer used by the Navigation 3 Home shell. + */ @Composable -fun MeetingsScreen( - homeStateHolder: HomeStateHolder, - viewModel: MeetingsCallViewModel = meetingsCallViewModel() +internal fun MeetingsScreen( + homeShellState: HomeShellState, + onOpenNewMeeting: (NewMeetingType) -> Unit, + viewModel: MeetingsCallViewModel = meetingsCallViewModel(), ) { val context = LocalContext.current AllMeetingsScreen( - lazyListState = homeStateHolder.lazyListStateFor(HomeDestination.Meetings), + lazyListState = homeShellState.lazyListStateFor(HomeDestination.Meetings), contentPadding = PaddingValues(bottom = dimensions().spacing80x), // to ensure last item is not obscured by FAB startCall = { conversationId -> - viewModel.startCallIfPossible(conversationId = conversationId) + viewModel.callManager.startCallIfPossible( + conversationId = conversationId, + conversationType = Conversation.Type.Group.Regular, + shouldCheckParticipantCount = false, // since this is a meeting, we don't need to check participant count + ) }, joinCall = { conversationId -> - viewModel.joinOngoingCall(conversationId = conversationId) + viewModel.callManager.joinOngoingCall(conversationId = conversationId) }, returnToCall = { conversationId -> context.startActivity( @@ -68,45 +64,22 @@ fun MeetingsScreen( ) ) }, - editMeeting = { meetingId -> - homeStateHolder.navigator.navigate(NavigationCommand(NewMeetingScreenDestination(NewMeetingType.Edit(meetingId)))) - }, ) viewModel.callManager.actions.HandleActions() viewModel.callManager.HandleJoinOrStartCallScreenDialogs() - NotEstablishedDialog(dialogState = viewModel.notEstablishedDialogState) - NewMeetingBottomSheet( - sheetState = homeStateHolder.newMeetingBottomSheetState, + sheetState = homeShellState.newMeetingBottomSheetState, onMeetNowClick = { - homeStateHolder.newMeetingBottomSheetState.hide { - homeStateHolder.navigator.navigate(NavigationCommand(NewMeetingScreenDestination(NewMeetingType.MeetNow))) + homeShellState.newMeetingBottomSheetState.hide { + onOpenNewMeeting(NewMeetingType.MeetNow) } }, onScheduleClick = { - homeStateHolder.newMeetingBottomSheetState.hide { - homeStateHolder.navigator.navigate(NavigationCommand(NewMeetingScreenDestination(NewMeetingType.Schedule))) + homeShellState.newMeetingBottomSheetState.hide { + onOpenNewMeeting(NewMeetingType.Schedule) } } ) } - -@Composable -private fun NotEstablishedDialog(dialogState: VisibilityState) { - VisibilityState(dialogState) { - WireDialog( - title = stringResource(meetingsR.string.meeting_join_failure_title), - text = stringResource(id = meetingsR.string.meeting_join_failure_description), - buttonsHorizontalAlignment = true, - onDismiss = dialogState::dismiss, - optionButton1Properties = WireDialogButtonProperties( - onClick = dialogState::dismiss, - text = stringResource(id = commonR.string.label_ok), - type = WireDialogButtonType.Primary, - state = WireButtonState.Default, - ) - ) - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index a62a989577a..eee6f2207ee 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -78,6 +78,7 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.SelfDeletionTimer import kotlin.math.roundToInt +@Suppress("SlotReused") @Composable fun MessageComposer( conversationId: ConversationId, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/actions/SelfDeletingMessageActionArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/actions/SelfDeletingMessageActionArgs.kt index aa5d7b1a7f3..61653494189 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/actions/SelfDeletingMessageActionArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/actions/SelfDeletingMessageActionArgs.kt @@ -17,17 +17,10 @@ */ package com.wire.android.ui.home.messagecomposer.actions -import com.wire.android.di.ScopedArgs import com.wire.kalium.logic.data.id.ConversationId import kotlinx.serialization.Serializable @Serializable data class SelfDeletingMessageActionArgs( val conversationId: ConversationId, -) : ScopedArgs { - override val key = "$ARGS_KEY:$conversationId" - - companion object { - const val ARGS_KEY = "SelfDeletingMessageActionArgsKey" - } -} +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3.kt new file mode 100644 index 00000000000..fb857aae2de --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3.kt @@ -0,0 +1,138 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.newconversation + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +/** + * Navigation 3 contract shared by the core steps which own one [NewConversationViewModel]. + * + * [flowId] is deliberately required instead of being derived from a route or back-stack position. + * The Navigation 3 flow decorator can therefore retain one Metro/ViewModel scope while individual + * entries are pushed and popped, including after saved-state restoration. + */ +sealed interface NewConversationRoute : SessionRoute { + override val flowId: String +} + +@Serializable +data class NewConversationSearchPeopleRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewConversationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateNewConversationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/new_conversation_search_people_screen" + + fun start(sessionId: WireSessionId): NewConversationSearchPeopleRoute { + val entryId = WireNavEntryId.random() + return NewConversationSearchPeopleRoute( + sessionId = sessionId, + flowId = entryId.value, + entryId = entryId, + ) + } + } +} + +@Serializable +data class NewGroupConversationSearchPeopleRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewConversationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateNewConversationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/new_group_conversation_search_people_screen" + } +} + +@Serializable +data class NewGroupNameRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewConversationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateNewConversationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/new_group_name_screen" + } +} + +@Serializable +data class GroupOptionRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewConversationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateNewConversationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/group_option_screen" + } +} + +@Serializable +data class ChannelAccessOnCreateRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewConversationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateNewConversationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/channel_access_on_create_screen" + } +} + +private fun validateNewConversationFlowId(flowId: String) { + require(flowId.isNotBlank()) { "A new-conversation flow id cannot be blank" } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Entries.kt new file mode 100644 index 00000000000..1b73eddcaef --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Entries.kt @@ -0,0 +1,251 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.ui.home.newconversation + +import androidx.compose.runtime.Composable +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.ui.home.newConversationViewModel +import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessOnCreateRouteScreen +import com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryRoute +import com.wire.android.ui.home.newconversation.groupOptions.GroupOptionRouteScreen +import com.wire.android.ui.home.newconversation.groupname.NewGroupNameRouteScreen +import com.wire.android.ui.home.newconversation.groupsearch.NewGroupConversationSearchPeopleRouteScreen +import com.wire.android.ui.home.newconversation.search.NewConversationSearchPeopleRouteScreen +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireViewModelOwner + +/** + * Cross-feature exits from the new-conversation flow. + * + * These use KMP-safe primitive identities. Each target feature will replace the callback with its + * own typed route as that feature is migrated, without changing the screen adapters below. + */ +internal interface NewConversationNavigation3Actions { + fun exitFlow() + fun openUserProfile(userIdValue: String, userIdDomain: String) + fun openServiceDetails( + serviceId: String, + providerId: String, + useNewAppsUi: Boolean, + ) + + /** + * Replaces the complete new-conversation flow with the created conversation, matching legacy + * `REMOVE_CURRENT_NESTED_GRAPH`. + */ + fun completeFlowWithCreatedConversation( + conversationIdValue: String, + conversationIdDomain: String, + ) + + /** + * Clears the whole stack and opens Home, matching the legacy discard action. + */ + fun discardFlowToHome() + fun openTeamPlan() +} + +/** + * Navigation 3 entries for all new-conversation steps except the separately migrated + * channel-history pilot. + * + * The start route retains the legacy bottom-up presentation while subsequent steps retain the + * nested graph's standard horizontal transition. + */ +internal fun newConversationNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: NewConversationNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry( + presentation = WireEntryPresentation.PopUp, + ) { route -> + NewConversationSearchPeopleNavigation3Entry(route, runtime, actions) + } + wireEntry { route -> + NewGroupConversationSearchPeopleNavigation3Entry(route, runtime, actions) + } + wireEntry { route -> + NewGroupNameNavigation3Entry(route, runtime, actions) + } + wireEntry { route -> + GroupOptionNavigation3Entry(route, runtime, actions) + } + wireEntry { route -> + ChannelAccessOnCreateNavigation3Entry(route, runtime) + } +} + +@Composable +private fun NewConversationSearchPeopleNavigation3Entry( + route: NewConversationSearchPeopleRoute, + runtime: WireNavigation3Runtime, + actions: NewConversationNavigation3Actions, +) { + val viewModel = newConversationFlowViewModel(route.flowId) + NewConversationSearchPeopleRouteScreen( + newConversationViewModel = viewModel, + onNavigateBack = { + if (!runtime.navigator.goBack()) actions.exitFlow() + }, + onOpenUserProfile = actions::openUserProfile, + onOpenServiceDetails = actions::openServiceDetails, + onStartGroupOrChannel = { + runtime.navigator.navigate( + WireNavigationCommand( + NewGroupConversationSearchPeopleRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ) + ) + ) + }, + onOpenTeamPlan = actions::openTeamPlan, + ) +} + +@Composable +private fun NewGroupConversationSearchPeopleNavigation3Entry( + route: NewGroupConversationSearchPeopleRoute, + runtime: WireNavigation3Runtime, + actions: NewConversationNavigation3Actions, +) { + NewGroupConversationSearchPeopleRouteScreen( + newConversationViewModel = newConversationFlowViewModel(route.flowId), + onNavigateBack = { runtime.navigator.goBack() }, + onOpenUserProfile = actions::openUserProfile, + onContinue = { + runtime.navigator.navigate( + WireNavigationCommand( + NewGroupNameRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ) + ) + ) + }, + ) +} + +@Composable +private fun NewGroupNameNavigation3Entry( + route: NewGroupNameRoute, + runtime: WireNavigation3Runtime, + actions: NewConversationNavigation3Actions, +) { + NewGroupNameRouteScreen( + newConversationViewModel = newConversationFlowViewModel(route.flowId), + onConversationCreated = { + actions.completeFlowWithCreatedConversation(it.value, it.domain) + }, + onContinueToOptions = { + runtime.navigator.navigate( + WireNavigationCommand( + GroupOptionRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ) + ) + ) + }, + onEditParticipants = { + runtime.navigator.navigate( + WireNavigationCommand( + destination = NewGroupConversationSearchPeopleRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ), + backStackMode = WireBackStackMode.UPDATE_EXISTING, + ) + ) + }, + onDiscard = actions::discardFlowToHome, + onNavigateBack = { runtime.navigator.goBack() }, + ) +} + +@Composable +private fun GroupOptionNavigation3Entry( + route: GroupOptionRoute, + runtime: WireNavigation3Runtime, + actions: NewConversationNavigation3Actions, +) { + GroupOptionRouteScreen( + newConversationViewModel = newConversationFlowViewModel(route.flowId), + onConversationCreated = { + actions.completeFlowWithCreatedConversation(it.value, it.domain) + }, + onOpenAccess = { + runtime.navigator.navigate( + WireNavigationCommand( + ChannelAccessOnCreateRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ) + ) + ) + }, + onOpenHistory = { + runtime.navigator.navigate( + WireNavigationCommand( + ChannelHistoryRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ) + ) + ) + }, + onEditParticipants = { + runtime.navigator.navigate( + WireNavigationCommand( + destination = NewGroupConversationSearchPeopleRoute( + sessionId = route.sessionId, + flowId = route.flowId, + ), + backStackMode = WireBackStackMode.UPDATE_EXISTING, + ) + ) + }, + onDiscard = actions::discardFlowToHome, + onNavigateBack = { runtime.navigator.goBack() }, + ) +} + +@Composable +private fun ChannelAccessOnCreateNavigation3Entry( + route: ChannelAccessOnCreateRoute, + runtime: WireNavigation3Runtime, +) { + ChannelAccessOnCreateRouteScreen( + newConversationViewModel = newConversationFlowViewModel(route.flowId), + onNavigateBack = { runtime.navigator.goBack() }, + ) +} + +@Composable +private fun newConversationFlowViewModel(flowId: String): NewConversationViewModel { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(flowId)) + return newConversationViewModel(viewModelStoreOwner = flowOwner) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelaccess/ChannelAccessOnCreateScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelaccess/ChannelAccessOnCreateScreen.kt index f0405e1a770..2ebb8caa6c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelaccess/ChannelAccessOnCreateScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelaccess/ChannelAccessOnCreateScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.newconversation.channelaccess -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -29,7 +28,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType @@ -40,16 +38,18 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes -@WireNewConversationDestination +/** + * Navigation-neutral screen adapter shared by the legacy and Navigation 3 hosts. + */ @Composable -fun ChannelAccessOnCreateScreen( - navigator: Navigator, +internal fun ChannelAccessOnCreateRouteScreen( newConversationViewModel: NewConversationViewModel, + onNavigateBack: () -> Unit, ) { WireScaffold( topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onNavigateBack, elevation = dimensions().spacing0x, title = stringResource(id = R.string.channel_access_label), titleContentDescription = stringResource(id = R.string.content_description_new_conversation_options_heading), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt index 89b08fc0f11..d778fb160bf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryCustomScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.newconversation.channelhistory -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -37,9 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.WireDropDown import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold @@ -52,27 +49,20 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import kotlinx.collections.immutable.toImmutableList -@WireNewConversationDestination( - navArgs = ChannelHistoryCustomArgs::class, - style = SlideNavigationAnimation::class, -) @Composable -fun ChannelHistoryCustomScreen( - navArgs: ChannelHistoryCustomArgs, - resultNavigator: ResultBackNavigator, +internal fun ChannelHistoryCustomRouteScreen( + currentType: ChannelHistoryType, + onNavigateBack: (ChannelHistoryType.On.Specific?) -> Unit, modifier: Modifier = Modifier, ) { - val specificCurrentType = navArgs.currentType as? ChannelHistoryType.On.Specific + val specificCurrentType = currentType as? ChannelHistoryType.On.Specific val amountState = rememberTextFieldState(specificCurrentType?.amount?.toString().orEmpty()) var timeState by rememberSaveable { mutableStateOf(specificCurrentType?.type ?: ChannelHistoryType.On.Specific.AmountType.Days) } fun navigateBack() { - amountState.text.toString().toIntOrNull()?.takeIf { it > 0 }?.let { - resultNavigator.setResult(ChannelHistoryCustomNavBackArgs(ChannelHistoryType.On.Specific(it, timeState))) - } - resultNavigator.navigateBack() + onNavigateBack(channelHistorySpecificOrNull(amountState.text.toString(), timeState)) } BackHandler(enabled = true, onBack = ::navigateBack) @@ -86,6 +76,14 @@ fun ChannelHistoryCustomScreen( ) } +internal fun channelHistorySpecificOrNull( + amount: String, + type: ChannelHistoryType.On.Specific.AmountType, +): ChannelHistoryType.On.Specific? = + amount.toIntOrNull() + ?.takeIf { it > 0 } + ?.let { ChannelHistoryType.On.Specific(it, type) } + @Composable fun ChannelHistoryCustomScreenContent( amountState: TextFieldState, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryLegacyMappers.kt new file mode 100644 index 00000000000..ede63f33449 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryLegacyMappers.kt @@ -0,0 +1,57 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.newconversation.channelhistory + +internal fun ChannelHistoryType.toSelection(): ChannelHistorySelection = when (this) { + ChannelHistoryType.Off -> ChannelHistorySelection.Off + ChannelHistoryType.On.Unlimited -> ChannelHistorySelection.Unlimited + is ChannelHistoryType.On.Specific -> ChannelHistorySelection.Specific( + amount = amount, + unit = type.toSelection(), + ) +} + +internal fun ChannelHistoryType.On.Specific.toSelection(): ChannelHistorySelection.Specific = + ChannelHistorySelection.Specific( + amount = amount, + unit = type.toSelection(), + ) + +internal fun ChannelHistorySelection.toLegacy(): ChannelHistoryType = when (this) { + ChannelHistorySelection.Off -> ChannelHistoryType.Off + ChannelHistorySelection.Unlimited -> ChannelHistoryType.On.Unlimited + is ChannelHistorySelection.Specific -> ChannelHistoryType.On.Specific( + amount = amount, + type = unit.toLegacy(), + ) +} + +private fun ChannelHistoryType.On.Specific.AmountType.toSelection() = + when (this) { + ChannelHistoryType.On.Specific.AmountType.Days -> ChannelHistorySelection.AmountUnit.DAYS + ChannelHistoryType.On.Specific.AmountType.Weeks -> ChannelHistorySelection.AmountUnit.WEEKS + ChannelHistoryType.On.Specific.AmountType.Months -> ChannelHistorySelection.AmountUnit.MONTHS + } + +private fun ChannelHistorySelection.AmountUnit.toLegacy() = + when (this) { + ChannelHistorySelection.AmountUnit.DAYS -> ChannelHistoryType.On.Specific.AmountType.Days + ChannelHistorySelection.AmountUnit.WEEKS -> ChannelHistoryType.On.Specific.AmountType.Weeks + ChannelHistorySelection.AmountUnit.MONTHS -> ChannelHistoryType.On.Specific.AmountType.Months + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3.kt new file mode 100644 index 00000000000..f3d4f3a8f3b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3.kt @@ -0,0 +1,106 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.newconversation.channelhistory + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +/** + * Navigation 3 contract for the channel-history step of the new-conversation flow. + * + * It intentionally lives next to the feature while the legacy generated destination remains + * active. [flowId] gives every step in one new-conversation flow the same ViewModel owner. + */ +@Serializable +data class ChannelHistoryRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A channel-history flow id cannot be blank" } + } + + companion object { + // Kept equal to the legacy baseRoute so screen analytics remain stable during migration. + const val ROUTE_ID = "app/channel_history_screen" + } +} + +@Serializable +data class ChannelHistoryCustomRoute( + override val sessionId: WireSessionId, + override val flowId: String, + val currentType: ChannelHistorySelection, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(flowId.isNotBlank()) { "A channel-history flow id cannot be blank" } + } + + companion object { + // Kept equal to the legacy baseRoute so screen analytics remain stable during migration. + const val ROUTE_ID = "app/channel_history_custom_screen" + } +} + +@Serializable +sealed interface ChannelHistorySelection { + @Serializable + data object Off : ChannelHistorySelection + + @Serializable + data object Unlimited : ChannelHistorySelection + + @Serializable + data class Specific( + val amount: Int, + val unit: AmountUnit, + ) : ChannelHistorySelection { + init { + require(amount > 0) { "A channel-history amount must be positive" } + } + } + + @Serializable + enum class AmountUnit { + DAYS, + WEEKS, + MONTHS, + } +} + +@Serializable +data class ChannelHistoryCustomResult( + val customType: ChannelHistorySelection.Specific, +) + +internal val ChannelHistoryCustomResultContract = WireNavResultContract( + WireNavResultContractId("new-conversation.channel-history.custom") +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Entries.kt new file mode 100644 index 00000000000..b2679a1d17d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Entries.kt @@ -0,0 +1,141 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.newconversation.channelhistory + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.ui.home.newConversationViewModel +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireViewModelOwner + +/** + * The Android persistence boundary for the KMP result contract. + * + * Keep this as one stable instance: the runtime deliberately rejects an unregistered look-alike + * contract so a serializer cannot be changed accidentally during state restoration. + */ +internal val ChannelHistoryCustomResultType = WireNavigation3ResultType( + contract = ChannelHistoryCustomResultContract, + serializer = ChannelHistoryCustomResult.serializer(), +) + +/** + * App-owned pilot contribution consumed by the future root Navigation 3 host. + * + * Keeping result types and entry providers together prevents the host from installing entries + * whose result channel was not registered in [WireNavigation3Runtime]. + */ +internal object ChannelHistoryNavigation3Pilot { + val resultTypes: List> = + listOf(ChannelHistoryCustomResultType) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + ): List = + listOf(channelHistoryNavigation3Entries(runtime)) +} + +/** + * Navigation 3 entries for the channel-history slice of new-conversation. + */ +internal fun channelHistoryNavigation3Entries( + runtime: WireNavigation3Runtime, +): WireEntryProviderInstaller = { + wireEntry { route -> + ChannelHistoryNavigation3Entry(route, runtime) + } + wireEntry { route -> + ChannelHistoryCustomNavigation3Entry(route, runtime) + } +} + +@Composable +private fun ChannelHistoryNavigation3Entry( + route: ChannelHistoryRoute, + runtime: WireNavigation3Runtime, +) { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + val viewModel = newConversationViewModel(viewModelStoreOwner = flowOwner) + var pendingRequestIdValue by rememberSaveable(route.entryId.value) { + mutableStateOf(null) + } + + val currentEntryId = runtime.navigator.currentRoute?.entryId + LaunchedEffect(pendingRequestIdValue, currentEntryId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + val requestId = pendingRequestIdValue?.let(::WireNavResultRequestId) + ?: return@LaunchedEffect + when (val result = runtime.consumeResult(requestId, ChannelHistoryCustomResultType)) { + is WireNavResult.Value -> { + viewModel.setChannelHistoryType(result.value.customType.toLegacy()) + pendingRequestIdValue = null + } + + WireNavResult.Canceled -> pendingRequestIdValue = null + null -> Unit + } + } + + ChannelHistoryScreenContent( + selectedHistoryOption = viewModel.newGroupState.channelHistoryType, + onHistoryOptionSelected = viewModel::setChannelHistoryType, + onOpenCustomChooser = { + val requestId = runtime.navigateForResult( + destination = ChannelHistoryCustomRoute( + sessionId = route.sessionId, + flowId = route.flowId, + currentType = viewModel.newGroupState.channelHistoryType.toSelection(), + ), + resultType = ChannelHistoryCustomResultType, + ) + pendingRequestIdValue = requestId?.value + }, + onBackPressed = runtime.navigator::goBack, + ) +} + +@Composable +private fun ChannelHistoryCustomNavigation3Entry( + route: ChannelHistoryCustomRoute, + runtime: WireNavigation3Runtime, +) { + ChannelHistoryCustomRouteScreen( + currentType = route.currentType.toLegacy(), + onNavigateBack = { selected -> + val result = selected + ?.toSelection() + ?.let(::ChannelHistoryCustomResult) + ?.let { WireNavResult.Value(it) } + ?: WireNavResult.Canceled + if (!runtime.completeCurrentAndPop(ChannelHistoryCustomResultType, result)) { + runtime.navigator.goBack() + } + }, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt index af3c425b1cd..e26efc6de77 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt @@ -23,15 +23,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryCustomScreenDestination -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireNewConversationDestination -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.WirePromotionCard import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -41,39 +34,9 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversations.details.options.ArrowType import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsItem -import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes -@WireNewConversationDestination( - style = SlideNavigationAnimation::class, -) -@Composable -fun ChannelHistoryScreen( - navigator: Navigator, - newConversationViewModel: NewConversationViewModel, - customResultRecipient: ResultRecipient, - modifier: Modifier = Modifier, -) { - customResultRecipient.onNavResult { result -> - when (result) { - is NavResult.Canceled -> {} - is NavResult.Value -> newConversationViewModel.setChannelHistoryType(result.value.customType) - } - } - - ChannelHistoryScreenContent( - selectedHistoryOption = newConversationViewModel.newGroupState.channelHistoryType, - onHistoryOptionSelected = newConversationViewModel::setChannelHistoryType, - onOpenCustomChooser = { - val navArgs = ChannelHistoryCustomArgs(newConversationViewModel.newGroupState.channelHistoryType) - navigator.navigate(NavigationCommand(ChannelHistoryCustomScreenDestination(navArgs))) - }, - onBackPressed = navigator::navigateBack, - modifier = modifier, - ) -} - @Suppress("CyclomaticComplexMethod") @Composable fun ChannelHistoryScreenContent( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt index b665fad48cb..8c0878a5332 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt @@ -35,18 +35,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration -import com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnCreateScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import com.wire.android.ui.common.TextWithLinkSuffix import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -81,18 +72,22 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.featureConfig.AppsAllowedProtocol import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult -@WireNewConversationDestination +/** + * Navigation-neutral adapter for the group/channel options step. + */ @Composable -fun GroupOptionScreen( - navigator: Navigator, +internal fun GroupOptionRouteScreen( newConversationViewModel: NewConversationViewModel, + onConversationCreated: (ConversationId) -> Unit, + onOpenAccess: () -> Unit, + onOpenHistory: () -> Unit, + onEditParticipants: () -> Unit, + onDiscard: () -> Unit, + onNavigateBack: () -> Unit, ) { - fun navigateToGroup(conversationId: ConversationId): Unit = - navigator.navigate(NavigationCommand(ConversationScreenDestination(conversationId), BackStackMode.REMOVE_CURRENT_NESTED_GRAPH)) - LaunchedEffect(newConversationViewModel.createGroupState) { (newConversationViewModel.createGroupState as? CreateGroupState.Created)?.let { - navigateToGroup(it.conversationId) + onConversationCreated(it.conversationId) } } @@ -100,12 +95,8 @@ fun GroupOptionScreen( groupOptionState = newConversationViewModel.groupOptionsState, createGroupState = newConversationViewModel.createGroupState, groupMetadataState = newConversationViewModel.newGroupState, - onAccessClicked = { - navigator.navigate(NavigationCommand(ChannelAccessOnCreateScreenDestination)) - }, - onHistoryClicked = { - navigator.navigate(NavigationCommand(ChannelHistoryScreenDestination)) - }, + onAccessClicked = onOpenAccess, + onHistoryClicked = onOpenHistory, onAllowGuestChanged = newConversationViewModel::onAllowGuestStatusChanged, onAllowServicesChanged = newConversationViewModel::onAllowServicesStatusChanged, onReadReceiptChanged = newConversationViewModel::onReadReceiptStatusChanged, @@ -115,17 +106,17 @@ fun GroupOptionScreen( } else { newConversationViewModel::createGroup }, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, onAllowGuestsDialogDismissed = newConversationViewModel::onAllowGuestsDialogDismissed, onNotAllowGuestsClicked = newConversationViewModel::onNotAllowGuestClicked, onAllowGuestsClicked = newConversationViewModel::onAllowGuestsClicked, onEditParticipantsClick = { newConversationViewModel.onCreateGroupErrorDismiss() - navigator.navigate(NavigationCommand(NewGroupConversationSearchPeopleScreenDestination, BackStackMode.UPDATE_EXISTED)) + onEditParticipants() }, onDiscardGroupCreationClick = { newConversationViewModel.onCreateGroupErrorDismiss() - navigator.navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) + onDiscard() }, onErrorDismissed = newConversationViewModel::onCreateGroupErrorDismiss, onEnableWireCellChanged = newConversationViewModel::onEnableWireCellChanged diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupname/NewGroupNameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupname/NewGroupNameScreen.kt index b3f947a2bfe..099a264864f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupname/NewGroupNameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupname/NewGroupNameScreen.kt @@ -18,20 +18,12 @@ package com.wire.android.ui.home.newconversation.groupname -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.groupname.GroupMetadataState import com.wire.android.ui.common.groupname.GroupNameMode import com.wire.android.ui.common.groupname.GroupNameScreen -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.GroupOptionScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.common.CreateGroupErrorDialog import com.wire.android.ui.home.newconversation.common.CreateGroupState @@ -39,21 +31,24 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId -@WireNewConversationDestination +/** + * Navigation-neutral adapter for the stateful new-group-name step. + */ @Composable -fun NewGroupNameScreen( - navigator: Navigator, +internal fun NewGroupNameRouteScreen( newConversationViewModel: NewConversationViewModel, + onConversationCreated: (ConversationId) -> Unit, + onContinueToOptions: () -> Unit, + onEditParticipants: () -> Unit, + onDiscard: () -> Unit, + onNavigateBack: () -> Unit, ) { - fun navigateToGroup(conversationId: ConversationId): Unit = - navigator.navigate(NavigationCommand(ConversationScreenDestination(conversationId), BackStackMode.REMOVE_CURRENT_NESTED_GRAPH)) - LaunchedEffect(Unit) { newConversationViewModel.observeGroupNameChanges() } LaunchedEffect(newConversationViewModel.createGroupState) { (newConversationViewModel.createGroupState as? CreateGroupState.Created)?.let { - navigateToGroup(it.conversationId) + onConversationCreated(it.conversationId) } } GroupNameScreen( @@ -61,13 +56,13 @@ fun NewGroupNameScreen( newGroupNameTextState = newConversationViewModel.newGroupNameTextState, onContinuePressed = { if (newConversationViewModel.newGroupState.isSelfTeamMember == true) { - navigator.navigate(NavigationCommand(GroupOptionScreenDestination)) + onContinueToOptions() } else { newConversationViewModel.createGroup() } }, onGroupNameErrorAnimated = newConversationViewModel::onGroupNameErrorAnimated, - onBackPressed = navigator::navigateBack + onBackPressed = onNavigateBack, ) (newConversationViewModel.createGroupState as? CreateGroupState.Error)?.let { CreateGroupErrorDialog( @@ -75,11 +70,11 @@ fun NewGroupNameScreen( onDismiss = newConversationViewModel::onCreateGroupErrorDismiss, onEditParticipantsList = { newConversationViewModel.onCreateGroupErrorDismiss() - navigator.navigate(NavigationCommand(NewGroupConversationSearchPeopleScreenDestination, BackStackMode.UPDATE_EXISTED)) + onEditParticipants() }, onCancel = { newConversationViewModel.onCreateGroupErrorDismiss() - navigator.navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) + onDiscard() }, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt index 3c47a0dfb9f..038ab8f897e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupsearch/NewGroupConversationSearchPeopleScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.newconversation.groupsearch -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -25,27 +24,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.ramcosta.composedestinations.generated.app.destinations.NewGroupNameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination import com.wire.android.model.ItemActionType import com.wire.android.search.SearchUsersAndAppsScreen import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.common.ContinueButton -import com.wire.kalium.logic.data.id.QualifiedID -@WireNewConversationDestination +/** + * Navigation-neutral adapter. Cross-feature targets are exposed as callbacks until their typed + * routes are owned by their respective migration batches. + */ @Composable -fun NewGroupConversationSearchPeopleScreen( - navigator: Navigator, +internal fun NewGroupConversationSearchPeopleRouteScreen( newConversationViewModel: NewConversationViewModel, + onNavigateBack: () -> Unit, + onOpenUserProfile: (value: String, domain: String) -> Unit, + onContinue: () -> Unit, ) { val onBackClicked = remember(Unit) { { newConversationViewModel.resetState() - navigator.navigateBack() + onNavigateBack() } } @@ -59,8 +58,7 @@ fun NewGroupConversationSearchPeopleScreen( SearchUsersAndAppsScreen( searchTitle = screenTitle, onOpenUserProfile = { contact -> - OtherUserProfileScreenDestination(QualifiedID(contact.id, contact.domain)) - .let { navigator.navigate(NavigationCommand(it)) } + onOpenUserProfile(contact.id, contact.domain) }, onContactChecked = newConversationViewModel::updateSelectedContacts, onClose = onBackClicked, @@ -72,7 +70,7 @@ fun NewGroupConversationSearchPeopleScreen( conversationProtocol = null, peopleBottomActions = { focusRequester -> ContinueButton( - onContinue = { navigator.navigate(NavigationCommand(NewGroupNameScreenDestination)) }, + onContinue = onContinue, buttonModifier = Modifier.focusRequester(focusRequester), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt index f7b148f7464..37b72206bce 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/search/NewConversationSearchPeopleScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.newconversation.search -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,43 +24,34 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation -import com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationTeamPlanStepScreenDestination import com.wire.android.model.ItemActionType import com.wire.android.search.SearchUsersAndAppsScreen import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.common.CreateRegularGroupOrChannelButtons -import com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanNavArgs -import com.wire.android.ui.userprofile.service.ServiceDetailsNavArgs -import com.wire.kalium.logic.data.id.QualifiedID -import com.wire.kalium.logic.data.user.BotService -import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult -@WireNewConversationDestination( - start = true, - style = PopUpNavigationAnimation::class -) +/** + * Navigation-neutral adapter for the root step. Primitive identity values keep its callback + * boundary independent from legacy Parcelable destination arguments. + */ @Composable -fun NewConversationSearchPeopleScreen( - navigator: Navigator, +internal fun NewConversationSearchPeopleRouteScreen( newConversationViewModel: NewConversationViewModel, + onNavigateBack: () -> Unit, + onOpenUserProfile: (value: String, domain: String) -> Unit, + onOpenServiceDetails: (value: String, domain: String, useNewAppsUi: Boolean) -> Unit, + onStartGroupOrChannel: () -> Unit, + onOpenTeamPlan: () -> Unit, ) { val showCreateTeamDialog = remember { mutableStateOf(false) } SearchUsersAndAppsScreen( searchTitle = stringResource(id = R.string.label_new_conversation), onOpenUserProfile = { contact -> - OtherUserProfileScreenDestination(QualifiedID(contact.id, contact.domain)) - .let { navigator.navigate(NavigationCommand(it)) } + onOpenUserProfile(contact.id, contact.domain) }, onContactChecked = newConversationViewModel::updateSelectedContacts, - onClose = navigator::navigateBack, + onClose = onNavigateBack, navigationIconType = NavigationIconType.Close(R.string.content_description_new_conversation_close_btn), itemActionType = ItemActionType.CLICK, shouldHideBottomActionForSearch = true, @@ -69,24 +59,10 @@ fun NewConversationSearchPeopleScreen( isAppsTabVisible = (newConversationViewModel.groupOptionsState.isTeamAllowedToUseApps is AppsAllowedResult.Enabled), conversationProtocol = null, onAppClicked = { contact -> - val serviceDetailsNavArgsId: ServiceDetailsNavArgs.Id = - if (newConversationViewModel.groupOptionsState.shouldShowNewAppsUi) { - ServiceDetailsNavArgs.Id.AppId( - UserId(contact.id, contact.domain) - ) - } else { - ServiceDetailsNavArgs.Id.BotServiceId( - BotService(id = contact.id, provider = contact.domain) - ) - } - - navigator.navigate( - NavigationCommand( - ServiceDetailsScreenDestination( - null, - serviceDetailsNavArgsId - ) - ) + onOpenServiceDetails( + contact.id, + contact.domain, + newConversationViewModel.groupOptionsState.shouldShowNewAppsUi, ) }, peopleBottomActions = if (newConversationViewModel.newGroupState.isGroupCreatingAllowed == true) { @@ -96,11 +72,11 @@ fun NewConversationSearchPeopleScreen( isUserAllowedToCreateChannels = newConversationViewModel.isChannelCreationPossible, onCreateNewRegularGroup = { newConversationViewModel.setIsChannel(false) - navigator.navigate(NavigationCommand(NewGroupConversationSearchPeopleScreenDestination)) + onStartGroupOrChannel() }, onCreateNewChannel = { newConversationViewModel.setIsChannel(true) - navigator.navigate(NavigationCommand(NewGroupConversationSearchPeopleScreenDestination)) + onStartGroupOrChannel() }, firstVisibleButtonModifier = Modifier.focusRequester(focusRequester), ) @@ -117,9 +93,7 @@ fun NewConversationSearchPeopleScreen( }, onCreateTeam = { showCreateTeamDialog.value = false - navigator.navigate( - NavigationCommand(TeamMigrationTeamPlanStepScreenDestination(TeamMigrationTeamPlanNavArgs())) - ) + onOpenTeamPlan() } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3Entries.kt new file mode 100644 index 00000000000..c043bea7db8 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3Entries.kt @@ -0,0 +1,295 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.appLogger +import com.wire.android.ui.home.settings.account.MyAccountRouteScreen +import com.wire.android.ui.home.settings.account.MyAccountUpdateKind +import com.wire.android.ui.home.settings.account.MyAccountUpdateNotification +import com.wire.android.ui.home.settings.account.color.ChangeUserColorRouteScreen +import com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameRouteScreen +import com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailRouteScreen +import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailRouteScreen +import com.wire.android.ui.home.settings.account.email.verifyEmail.toViewModelArgs +import com.wire.android.ui.home.settings.account.handle.ChangeHandleRouteScreen +import com.wire.android.ui.settings.about.AboutThisAppRouteScreen +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireNavigationCommand + +internal val SettingsAccountUpdateNavigation3ResultType = WireNavigation3ResultType( + contract = SettingsAccountUpdateResultContract, + serializer = SettingsAccountUpdateResult.serializer(), +) + +internal fun settingsAccountNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry { route -> + AboutThisAppNavigation3Entry(runtime, route, actions) + } + wireEntry { route -> + MyAccountNavigation3Entry(runtime, route, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + ChangeEmailNavigation3Entry(runtime, route, actions) + } + wireEntry { route -> + VerifyEmailNavigation3Entry(runtime, route, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + ChangeUserColorNavigation3Entry(runtime, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + ChangeHandleNavigation3Entry(runtime, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + ChangeDisplayNameNavigation3Entry(runtime, actions) + } +} + +@Composable +private fun AboutThisAppNavigation3Entry( + runtime: WireNavigation3Runtime, + route: AboutThisAppRoute, + actions: SettingsNavigation3Actions, +) { + AboutThisAppRouteScreen( + viewModel = com.wire.android.ui.debug.aboutThisAppViewModel(), + onBackPressed = runtime.backOrExitAccount(actions), + onItemClicked = { item -> + item.toNavigation3Route(route.sessionId)?.let { target -> + runtime.navigator.navigate(WireNavigationCommand(target)) + } ?: actions.open( + when (item) { + SettingsItem.WireWebsite -> SettingsNavigation3Destination.WIRE_WEBSITE + SettingsItem.TermsOfUse -> SettingsNavigation3Destination.TERMS_OF_USE + SettingsItem.PrivacyPolicy -> SettingsNavigation3Destination.PRIVACY_POLICY + else -> error("Unsupported About app item: $item") + } + ) + }, + ) +} + +@Composable +private fun MyAccountNavigation3Entry( + runtime: WireNavigation3Runtime, + route: MyAccountRoute, + actions: SettingsNavigation3Actions, +) { + var displayNameRequestId by rememberSaveable(route.entryId.value, "display-name") { + mutableStateOf(null) + } + var handleRequestId by rememberSaveable(route.entryId.value, "handle") { + mutableStateOf(null) + } + var colorRequestId by rememberSaveable(route.entryId.value, "color") { + mutableStateOf(null) + } + var updateNotification by remember(route.entryId.value) { + mutableStateOf(null) + } + + ConsumeAccountUpdateResult( + runtime = runtime, + route = route, + requestIdValue = displayNameRequestId, + kind = MyAccountUpdateKind.DISPLAY_NAME, + onConsumed = { displayNameRequestId = null }, + onNotification = { updateNotification = it }, + ) + ConsumeAccountUpdateResult( + runtime = runtime, + route = route, + requestIdValue = handleRequestId, + kind = MyAccountUpdateKind.HANDLE, + onConsumed = { handleRequestId = null }, + onNotification = { updateNotification = it }, + ) + ConsumeAccountUpdateResult( + runtime = runtime, + route = route, + requestIdValue = colorRequestId, + kind = MyAccountUpdateKind.USER_COLOR, + onConsumed = { colorRequestId = null }, + onNotification = { updateNotification = it }, + ) + + MyAccountRouteScreen( + viewModel = myAccountViewModel(), + deleteAccountViewModel = deleteAccountViewModel(), + onNavigateBack = runtime.backOrExitAccount(actions), + onChangeDisplayName = { + displayNameRequestId = runtime.navigateForResult( + ChangeDisplayNameRoute(route.sessionId), + SettingsAccountUpdateNavigation3ResultType, + )?.value + }, + onChangeHandle = { + handleRequestId = runtime.navigateForResult( + ChangeHandleRoute(route.sessionId), + SettingsAccountUpdateNavigation3ResultType, + )?.value + }, + onChangeEmail = { + runtime.navigator.navigate(WireNavigationCommand(ChangeEmailRoute(route.sessionId))) + }, + onChangeUserColor = { + colorRequestId = runtime.navigateForResult( + ChangeUserColorRoute(route.sessionId), + SettingsAccountUpdateNavigation3ResultType, + )?.value + }, + updateNotification = updateNotification, + ) +} + +@Composable +private fun ConsumeAccountUpdateResult( + runtime: WireNavigation3Runtime, + route: MyAccountRoute, + requestIdValue: String?, + kind: MyAccountUpdateKind, + onConsumed: () -> Unit, + onNotification: (MyAccountUpdateNotification) -> Unit, +) { + val currentEntryId = runtime.navigator.currentRoute?.entryId + LaunchedEffect(requestIdValue, currentEntryId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + val requestId = requestIdValue?.let(::WireNavResultRequestId) ?: return@LaunchedEffect + when ( + val result = runtime.consumeResult( + requestId, + SettingsAccountUpdateNavigation3ResultType, + ) + ) { + is WireNavResult.Value -> { + onNotification( + MyAccountUpdateNotification( + requestId = requestId.value, + kind = kind, + successful = result.value.successful, + ) + ) + onConsumed() + } + + WireNavResult.Canceled -> { + appLogger.i("Error with receiving navigation back args") + onConsumed() + } + null -> Unit + } + } +} + +@Composable +private fun ChangeEmailNavigation3Entry( + runtime: WireNavigation3Runtime, + route: ChangeEmailRoute, + actions: SettingsNavigation3Actions, +) { + ChangeEmailRouteScreen( + viewModel = changeEmailViewModel(), + onBackPressed = runtime.backOrExitAccount(actions), + onVerifyEmail = { email -> + runtime.navigator.navigate( + WireNavigationCommand( + destination = VerifyEmailRoute(route.sessionId, email), + backStackMode = WireBackStackMode.REMOVE_CURRENT, + ) + ) + }, + ) +} + +@Composable +private fun VerifyEmailNavigation3Entry( + runtime: WireNavigation3Runtime, + route: VerifyEmailRoute, + actions: SettingsNavigation3Actions, +) { + VerifyEmailRouteScreen( + viewModel = verifyEmailViewModel(route.toViewModelArgs()), + onBackPressed = runtime.backOrExitAccount(actions), + ) +} + +@Composable +private fun ChangeUserColorNavigation3Entry( + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, +) { + ChangeUserColorRouteScreen( + viewModel = changeUserColorViewModel(), + onBackPressed = runtime.backOrExitAccount(actions), + onCompleted = { runtime.completeAccountUpdate(it, actions) }, + ) +} + +@Composable +private fun ChangeHandleNavigation3Entry( + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, +) { + ChangeHandleRouteScreen( + viewModel = changeHandleViewModel(), + onBackPressed = runtime.backOrExitAccount(actions), + onCompleted = { runtime.completeAccountUpdate(it, actions) }, + ) +} + +@Composable +private fun ChangeDisplayNameNavigation3Entry( + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, +) { + ChangeDisplayNameRouteScreen( + viewModel = changeDisplayNameViewModel(), + onBackPressed = runtime.backOrExitAccount(actions), + onCompleted = { runtime.completeAccountUpdate(it, actions) }, + ) +} + +private fun WireNavigation3Runtime.completeAccountUpdate( + successful: Boolean, + actions: SettingsNavigation3Actions, +) { + if ( + !completeCurrentAndPop( + SettingsAccountUpdateNavigation3ResultType, + WireNavResult.Value(SettingsAccountUpdateResult(successful)), + ) + ) { + if (!navigator.goBack()) actions.exitSettings() + } +} + +private fun WireNavigation3Runtime.backOrExitAccount( + actions: SettingsNavigation3Actions, +): () -> Unit = { + if (!navigator.goBack()) actions.exitSettings() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt index c5e0c7aa244..746b22dcfa9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt @@ -30,30 +30,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow -import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.GiveFeedbackDestination -import com.wire.android.navigation.PrivacyPolicyScreenDestination -import com.wire.android.navigation.ReportMisuseScreenDestination -import com.wire.android.navigation.SupportScreenDestination -import com.wire.android.navigation.TermsOfUseScreenDestination -import com.wire.android.navigation.WireWebsiteScreenDestination import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.clickable import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.rowitem.RowItemTemplate -import com.ramcosta.composedestinations.generated.app.destinations.AboutThisAppScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.AppSettingsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.BackupAndRestoreScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CustomizationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DebugScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DependenciesScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LicensesScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MyAccountScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NetworkSettingsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.PrivacySettingsConfigScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfDevicesScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography @@ -135,7 +117,6 @@ fun SettingsItem( sealed class SettingsItem(open val id: String, open val title: UIText) { sealed class DirectionItem( - val direction: Direction, override val id: String, override val title: UIText ) : SettingsItem(id, title) @@ -154,97 +135,81 @@ sealed class SettingsItem(open val id: String, open val title: UIText) { data object AppSettings : DirectionItem( id = "general_app_settings", title = UIText.StringResource(R.string.app_settings_screen_title), - direction = AppSettingsScreenDestination ) data object YourAccount : DirectionItem( id = "your_account_settings", title = UIText.StringResource(R.string.settings_your_account_label), - direction = MyAccountScreenDestination ) data object Customization : DirectionItem( id = "customization_settings", title = UIText.StringResource(R.string.settings_customization_label), - direction = CustomizationScreenDestination ) data object NetworkSettings : DirectionItem( id = "network_settings", title = UIText.StringResource(R.string.settings_network_settings_label), - direction = NetworkSettingsScreenDestination ) data object ManageDevices : DirectionItem( id = "manage_devices", title = UIText.StringResource(R.string.settings_manage_devices_label), - direction = SelfDevicesScreenDestination ) data object PrivacySettings : DirectionItem( id = "privacy_settings", title = UIText.StringResource(R.string.settings_privacy_settings_label), - direction = PrivacySettingsConfigScreenDestination ) data object TermsOfUse : DirectionItem( id = "terms_of_use", title = UIText.StringResource(R.string.settings_terms_of_use_label), - direction = TermsOfUseScreenDestination ) data object WireWebsite : DirectionItem( id = "terms_of_use", title = UIText.StringResource(R.string.settings_wire_website_label), - direction = WireWebsiteScreenDestination ) data object PrivacyPolicy : DirectionItem( id = "privacy_policy", title = UIText.StringResource(R.string.settings_privacy_policy_label), - direction = PrivacyPolicyScreenDestination ) data object Licenses : DirectionItem( id = "other_licenses", title = UIText.StringResource(R.string.settings_licenses_settings_label), - direction = LicensesScreenDestination ) data object Dependencies : DirectionItem( id = "other_licenses", title = UIText.StringResource(R.string.settings_dependencies_label), - direction = DependenciesScreenDestination ) data object BackupAndRestore : DirectionItem( id = "backups_backup_and_restore", title = UIText.StringResource(R.string.backup_and_restore_screen_title), - direction = BackupAndRestoreScreenDestination ) data object Support : DirectionItem( id = "other_support", title = UIText.StringResource(R.string.support_screen_title), - direction = SupportScreenDestination ) data object ReportMisuse : DirectionItem( id = "other_report_misuse", title = UIText.StringResource(R.string.report_misuse_screen_title), - direction = ReportMisuseScreenDestination ) data object DebugSettings : DirectionItem( id = "other_debug_settings", title = UIText.StringResource(R.string.debug_settings_screen_title), - direction = DebugScreenDestination ) data object GiveFeedback : DirectionItem( id = "give_feedback", title = UIText.StringResource(R.string.give_feedback_screen_title), - direction = GiveFeedbackDestination ) data object ReportBug : ActionItem( @@ -261,7 +226,6 @@ sealed class SettingsItem(open val id: String, open val title: UIText) { data object AboutApp : DirectionItem( id = "about_app", title = UIText.StringResource(R.string.about_app_screen_title), - direction = AboutThisAppScreenDestination ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsLegacyMappers.kt new file mode 100644 index 00000000000..f41cb361faa --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsLegacyMappers.kt @@ -0,0 +1,45 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.settings + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId + +/** + * Temporary adapter at the settings-list boundary. + * + * The settings screen can adopt this mapping before the legacy [SettingsItem.DirectionItem] + * representation is removed. Items outside this migration batch deliberately return `null`. + */ +internal fun SettingsItem.toNavigation3Route( + sessionId: WireSessionId, + entryId: WireNavEntryId = WireNavEntryId.random(), +): SessionRoute? = when (this) { + SettingsItem.AppSettings -> AppSettingsRoute(sessionId, entryId) + SettingsItem.YourAccount -> MyAccountRoute(sessionId, entryId) + SettingsItem.AboutApp -> AboutThisAppRoute(sessionId, entryId) + SettingsItem.NetworkSettings -> NetworkSettingsRoute(sessionId, entryId) + SettingsItem.PrivacySettings -> PrivacySettingsRoute(sessionId, entryId) + SettingsItem.Customization -> CustomizationSettingsRoute(sessionId, entryId) + SettingsItem.Licenses -> LicensesSettingsRoute(sessionId, entryId) + SettingsItem.Dependencies -> DependenciesSettingsRoute(sessionId, entryId) + SettingsItem.BackupAndRestore -> BackupAndRestoreSettingsRoute(sessionId, entryId) + else -> null +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3.kt new file mode 100644 index 00000000000..af1d4868888 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3.kt @@ -0,0 +1,242 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.settings + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +/** + * Navigation 3 contracts for the settings screens. + * + * They live next to the feature and carry the full structured session identity so Metro can + * resolve the correct session-scoped graph without reading route strings or Android Bundles. + */ +@Serializable +data class SettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/settings_screen" + } +} + +@Serializable +data class AppSettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/app_settings_screen" + } +} + +@Serializable +data class NetworkSettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/network_settings_screen" + } +} + +@Serializable +data class PrivacySettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/privacy_settings_config_screen" + } +} + +@Serializable +data class CustomizationSettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/customization_screen" + } +} + +@Serializable +data class LicensesSettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/licenses_screen" + } +} + +@Serializable +data class DependenciesSettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/dependencies_screen" + } +} + +@Serializable +data class BackupAndRestoreSettingsRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/backup_and_restore_screen" + } +} + +@Serializable +data class AboutThisAppRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/about_this_app_screen" + } +} + +@Serializable +data class MyAccountRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/my_account_screen" + } +} + +@Serializable +data class ChangeEmailRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/change_email_screen" + } +} + +@Serializable +data class VerifyEmailRoute( + override val sessionId: WireSessionId, + val newEmail: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(newEmail.isNotBlank()) { "The email to verify cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/verify_email_screen" + } +} + +@Serializable +data class ChangeUserColorRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/change_user_color_screen" + } +} + +@Serializable +data class ChangeHandleRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/change_handle_screen" + } +} + +@Serializable +data class ChangeDisplayNameRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/change_display_name_screen" + } +} + +@Serializable +data class SettingsAccountUpdateResult( + val successful: Boolean, +) + +internal val SettingsAccountUpdateResultContract = + WireNavResultContract( + WireNavResultContractId("settings.account-update") + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Entries.kt new file mode 100644 index 00000000000..05c7f05fe84 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Entries.kt @@ -0,0 +1,190 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.settings + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.ui.home.settings.about.dependencies.DependenciesScreen +import com.wire.android.ui.home.settings.about.licenses.LicensesScreen +import com.wire.android.ui.home.settings.appearance.CustomizationScreen +import com.wire.android.ui.home.settings.appsettings.AppSettingsScreen +import com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsScreen +import com.wire.android.ui.home.settings.backup.BackupAndRestoreScreen +import com.wire.android.ui.home.settings.privacy.PrivacySettingsConfigScreen +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireSessionId + +/** + * Destinations outside the settings batch. + * + * The contribution deliberately exposes semantic actions instead of generated Compose + * Destinations directions. Each enum value can be replaced by its typed route independently when + * the owning feature is migrated. + */ +internal enum class SettingsNavigation3Destination { + ACCOUNT, + MANAGE_DEVICES, + TERMS_OF_USE, + WIRE_WEBSITE, + PRIVACY_POLICY, + SUPPORT, + REPORT_MISUSE, + DEBUG_SETTINGS, + GIVE_FEEDBACK, + ABOUT_APP, +} + +internal sealed interface SettingsNavigation3Target { + data class Route(val route: SessionRoute) : SettingsNavigation3Target + data class External(val destination: SettingsNavigation3Destination) : SettingsNavigation3Target +} + +internal interface SettingsNavigation3Actions { + fun open(destination: SettingsNavigation3Destination) + fun openAppLockSetup() + fun openConversations() + + /** + * Used only when a detail route is restored/deep-linked without its settings parent. + */ + fun exitSettings() +} + +/** + * Complete Navigation 3 contribution for the migrated settings batches. + * + * The host consumes the installers and result type as one unit, while route and back-stack + * decisions stay in this feature. + */ +internal object SettingsNavigation3Contribution { + val resultTypes: List> = + listOf(SettingsAccountUpdateNavigation3ResultType) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, + ): List = + listOf( + settingsNavigation3Entries(runtime, actions), + settingsAccountNavigation3Entries(runtime, actions), + ) +} + +internal fun settingsNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry { route -> + SettingsNavigation3Root( + sessionId = route.sessionId, + runtime = runtime, + actions = actions, + ) + } + wireEntry { + AppSettingsScreen() + } + wireEntry { + NetworkSettingsScreen(onBackPressed = runtime.backOrExit(actions)) + } + wireEntry { + PrivacySettingsConfigScreen(onBackPressed = runtime.backOrExit(actions)) + } + wireEntry { + CustomizationScreen(onBackPressed = runtime.backOrExit(actions)) + } + wireEntry { + LicensesScreen(onBackPressed = runtime.backOrExit(actions)) + } + wireEntry { + DependenciesScreen(onBackPressed = runtime.backOrExit(actions)) + } + wireEntry { + BackupAndRestoreScreen( + onBackPressed = runtime.backOrExit(actions), + onOpenConversations = actions::openConversations, + ) + } +} + +/** + * Shared root renderer for a standalone/deep-linked Settings entry and Settings selected inside + * the Home shell. + */ +@Composable +internal fun SettingsNavigation3Root( + sessionId: WireSessionId, + runtime: WireNavigation3Runtime, + actions: SettingsNavigation3Actions, + lazyListState: LazyListState = rememberLazyListState(), +) { + SettingsScreen( + lazyListState = lazyListState, + onOpenAppLockSetup = actions::openAppLockSetup, + onDirectionItemClicked = { item -> + when (val target = item.toNavigation3Target(sessionId)) { + is SettingsNavigation3Target.Route -> + runtime.navigator.navigate(WireNavigationCommand(target.route)) + + is SettingsNavigation3Target.External -> + actions.open(target.destination) + } + }, + ) +} + +private fun WireNavigation3Runtime.backOrExit( + actions: SettingsNavigation3Actions, +): () -> Unit = { + if (!navigator.goBack()) actions.exitSettings() +} + +internal fun SettingsItem.DirectionItem.toNavigation3Target( + sessionId: WireSessionId, +): SettingsNavigation3Target = + toNavigation3Route(sessionId)?.let(SettingsNavigation3Target::Route) + ?: SettingsNavigation3Target.External( + when (this) { + SettingsItem.YourAccount, + SettingsItem.AboutApp, + -> error("A typed route was expected for $this") + SettingsItem.ManageDevices -> SettingsNavigation3Destination.MANAGE_DEVICES + SettingsItem.TermsOfUse -> SettingsNavigation3Destination.TERMS_OF_USE + SettingsItem.WireWebsite -> SettingsNavigation3Destination.WIRE_WEBSITE + SettingsItem.PrivacyPolicy -> SettingsNavigation3Destination.PRIVACY_POLICY + SettingsItem.Support -> SettingsNavigation3Destination.SUPPORT + SettingsItem.ReportMisuse -> SettingsNavigation3Destination.REPORT_MISUSE + SettingsItem.DebugSettings -> SettingsNavigation3Destination.DEBUG_SETTINGS + SettingsItem.GiveFeedback -> SettingsNavigation3Destination.GIVE_FEEDBACK + SettingsItem.AppSettings, + SettingsItem.Customization, + SettingsItem.NetworkSettings, + SettingsItem.PrivacySettings, + SettingsItem.Licenses, + SettingsItem.Dependencies, + SettingsItem.BackupAndRestore, + -> error("A typed route was expected for $this") + } + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt index 2439fa256d5..8d57e1c5b0c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt @@ -30,19 +30,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import com.ramcosta.composedestinations.generated.app.destinations.SetLockCodeScreenDestination import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.appLogger import com.wire.android.model.Clickable -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.annotation.app.WireHomeDestination -import com.wire.android.navigation.handleNavigation import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.wire.android.ui.home.HomeStateHolder import com.wire.android.ui.theme.WireTheme import com.wire.android.util.debug.LocalFeatureVisibilityFlags import com.wire.android.util.logging.LogShareLauncher @@ -50,18 +43,19 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.sectionWithElements -@WireHomeDestination @Composable -fun SettingsScreen( - homeStateHolder: HomeStateHolder, - viewModel: SettingsViewModel = settingsScreenViewModel() +internal fun SettingsScreen( + onOpenAppLockSetup: () -> Unit, + onDirectionItemClicked: (SettingsItem.DirectionItem) -> Unit, + lazyListState: LazyListState = rememberLazyListState(), + viewModel: SettingsViewModel = settingsScreenViewModel(), ) { val turnAppLockOffDialogState = rememberVisibilityState() - val onAppLockSwitchClicked: (Boolean) -> Unit = remember { + val onAppLockSwitchClicked: (Boolean) -> Unit = remember(onOpenAppLockSetup) { { isChecked -> if (isChecked) { - homeStateHolder.navigator.navigate(NavigationCommand(SetLockCodeScreenDestination, BackStackMode.NONE)) + onOpenAppLockSetup() } else { turnAppLockOffDialogState.show(Unit) } @@ -81,18 +75,13 @@ fun SettingsScreen( ) } SettingsScreenContent( - lazyListState = homeStateHolder.lazyListStateFor(HomeDestination.Settings), + lazyListState = lazyListState, settingsState = viewModel.state, - onItemClicked = remember(context, homeStateHolder.navigator, logShareLauncher, viewModel) { + onItemClicked = remember(context, onDirectionItemClicked, logShareLauncher, viewModel) { { item -> when (item) { - is SettingsItem.DirectionItem -> item.direction.handleNavigation( - context = context, - handleOtherDirection = { direction -> - homeStateHolder.navigator.navigate(NavigationCommand(direction)) - } - ) + is SettingsItem.DirectionItem -> onDirectionItemClicked(item) is SettingsItem.ActionItem -> when (item) { SettingsItem.ReportBug -> logShareLauncher.shareBugReport { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt index 198d93ebb6a..994d6f71822 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsViewModelGraph.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -@file:Suppress("TooManyFunctions") +@file:Suppress("TooManyFunctions", "MatchingDeclarationName") package com.wire.android.ui.home.settings @@ -23,7 +23,8 @@ import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel +import com.wire.android.di.metro.wireMetroViewModel import com.wire.android.ui.home.appLock.forgot.ForgotLockScreenViewModel import com.wire.android.ui.home.appLock.set.SetLockScreenViewModel import com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsViewModel @@ -34,44 +35,56 @@ import com.wire.android.ui.home.settings.account.deleteAccount.DeleteAccountView import com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameViewModel import com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailViewModel import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel +import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModelArgs import com.wire.android.ui.home.settings.account.handle.ChangeHandleViewModel import com.wire.android.ui.home.settings.appearance.CustomizationViewModel import com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsViewModel import com.wire.android.ui.home.settings.backup.BackupAndRestoreViewModel import com.wire.android.ui.home.settings.privacy.PrivacySettingsViewModel import com.wire.android.ui.settings.devices.DeviceDetailsViewModel +import com.wire.android.ui.settings.devices.DeviceDetailsViewModelArgs import com.wire.android.ui.settings.devices.SelfDevicesViewModel import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModelArgs import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel +import com.wire.android.ui.userprofile.other.OtherUserProfileViewModelArgs import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel +import com.wire.android.ui.userprofile.qr.SelfQrCodeViewModelArgs import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel import com.wire.android.ui.userprofile.service.ServiceDetailsViewModel import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl +import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelArgs import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory -@Composable -inline fun settingsViewModel( - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, - key: String? = null, -): VM where VM : ViewModel = - sessionKeyedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = key, - ) +interface SettingsManualViewModelFactory : ManualViewModelAssistedFactory { + fun verifyEmailViewModel(arguments: VerifyEmailViewModelArgs): VerifyEmailViewModel + fun deviceDetailsViewModel(arguments: DeviceDetailsViewModelArgs): DeviceDetailsViewModel + fun e2eiCertificateDetailsViewModel( + arguments: E2eiCertificateDetailsViewModelArgs, + ): E2eiCertificateDetailsViewModel + + fun selfQRCodeViewModel(arguments: SelfQrCodeViewModelArgs): SelfQRCodeViewModel + fun otherUserProfileScreenViewModel( + arguments: OtherUserProfileViewModelArgs, + ): OtherUserProfileScreenViewModel + + fun serviceDetailsViewModel( + arguments: ServiceDetailsViewModelArgs, + ): ServiceDetailsViewModelImpl +} @Composable -inline fun settingsSavedStateViewModel( +inline fun settingsViewModel( viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, key: String? = null, ): VM where VM : ViewModel = - settingsViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = key, + wireMetroViewModel( + owner = viewModelStoreOwner, + instanceKey = key, ) @Composable @@ -95,10 +108,6 @@ fun changeUserColorViewModel(): ChangeUserColorViewModel = @Composable fun changeEmailViewModel(): ChangeEmailViewModel = settingsViewModel() -@Composable -fun verifyEmailViewModel(): VerifyEmailViewModel = - settingsSavedStateViewModel() - @Composable fun changeHandleViewModel(): ChangeHandleViewModel = settingsViewModel() @@ -138,12 +147,24 @@ fun enterLockScreenViewModel(): EnterLockScreenViewModel = fun selfDevicesViewModel(): SelfDevicesViewModel = settingsViewModel() @Composable -fun deviceDetailsViewModel(): DeviceDetailsViewModel = - settingsSavedStateViewModel() +fun deviceDetailsViewModel(arguments: DeviceDetailsViewModelArgs): DeviceDetailsViewModel = + wireAssistedMetroViewModel { _ -> + deviceDetailsViewModel(arguments) + } + +@Composable +fun e2eiCertificateDetailsViewModel( + arguments: E2eiCertificateDetailsViewModelArgs, +): E2eiCertificateDetailsViewModel = + wireAssistedMetroViewModel { _ -> + e2eiCertificateDetailsViewModel(arguments) + } @Composable -fun e2eiCertificateDetailsViewModel(): E2eiCertificateDetailsViewModel = - settingsSavedStateViewModel() +fun verifyEmailViewModel(arguments: VerifyEmailViewModelArgs): VerifyEmailViewModel = + wireAssistedMetroViewModel { _ -> + verifyEmailViewModel(arguments) + } @Composable fun avatarPickerViewModel(): AvatarPickerViewModel = @@ -154,8 +175,10 @@ fun selfUserProfileViewModel(): SelfUserProfileViewModel = settingsViewModel() @Composable -fun selfQRCodeViewModel(): SelfQRCodeViewModel = - settingsSavedStateViewModel() +fun selfQRCodeViewModel(arguments: SelfQrCodeViewModelArgs): SelfQRCodeViewModel = + wireAssistedMetroViewModel { _ -> + selfQRCodeViewModel(arguments) + } @Composable fun teamMigrationViewModel( @@ -163,9 +186,17 @@ fun teamMigrationViewModel( ): TeamMigrationViewModel = settingsViewModel(viewModelStoreOwner = viewModelStoreOwner) @Composable -fun otherUserProfileScreenViewModel(): OtherUserProfileScreenViewModel = - settingsSavedStateViewModel() +fun otherUserProfileScreenViewModel( + arguments: OtherUserProfileViewModelArgs, +): OtherUserProfileScreenViewModel = + wireAssistedMetroViewModel { _ -> + otherUserProfileScreenViewModel(arguments) + } @Composable -fun serviceDetailsViewModel(): ServiceDetailsViewModel = - settingsSavedStateViewModel() +fun serviceDetailsViewModel( + arguments: ServiceDetailsViewModelArgs, +): ServiceDetailsViewModel = + wireAssistedMetroViewModel { _ -> + serviceDetailsViewModel(arguments) + } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesScreen.kt index c3f5bd4be85..0b0de67ceee 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.settings.about.dependencies -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.wrapContentWidth @@ -30,7 +29,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.debug.dependenciesViewModel import com.wire.android.ui.common.rowitem.RowItemTemplate import com.wire.android.ui.common.dimensions @@ -43,14 +41,13 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @Composable -@WireRootDestination fun DependenciesScreen( - navigator: Navigator, + onBackPressed: () -> Unit, viewModel: DependenciesViewModel = dependenciesViewModel() ) { WireScaffold(topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onBackPressed, elevation = 0.dp, title = stringResource(id = R.string.settings_dependencies_label) ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesViewModel.kt index 21f4d271487..a05badeb143 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/dependencies/DependenciesViewModel.kt @@ -25,9 +25,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.di.ApplicationContext import com.wire.android.util.getDependenciesVersion +import dev.zacsweers.metro.Inject import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.launch -import dev.zacsweers.metro.Inject class DependenciesViewModel @Inject constructor( @ApplicationContext val context: Context diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesScreen.kt index abe5de424b3..73c59afcb46 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.settings.about.licenses -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -31,20 +30,18 @@ import androidx.compose.ui.unit.dp import com.mikepenz.aboutlibraries.entity.Library import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.debug.licensesViewModel import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -@WireRootDestination @Composable fun LicensesScreen( - navigator: Navigator, + onBackPressed: () -> Unit, viewModel: LicensesViewModel = licensesViewModel() ) { WireScaffold(topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onBackPressed, elevation = 0.dp, title = stringResource(id = R.string.settings_licenses_settings_label) ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesViewModel.kt index b12ed4ab8eb..2b39290824d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/about/licenses/LicensesViewModel.kt @@ -26,9 +26,9 @@ import androidx.lifecycle.viewModelScope import com.wire.android.di.ApplicationContext import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.util.withContext +import dev.zacsweers.metro.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import dev.zacsweers.metro.Inject class LicensesViewModel @Inject constructor( @ApplicationContext context: Context diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt index be123d3588e..0ed83f37169 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.settings.account -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,26 +31,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.ui.home.settings.deleteAccountViewModel -import com.wire.android.ui.home.settings.myAccountViewModel -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient -import com.ramcosta.composedestinations.spec.DestinationSpec import com.wire.android.R -import com.wire.android.appLogger import com.wire.android.model.Clickable -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -61,10 +51,6 @@ import com.wire.android.ui.common.rowitem.RowItemTemplate import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.ChangeDisplayNameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ChangeEmailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ChangeHandleScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ChangeUserColorScreenDestination import com.wire.android.ui.home.settings.account.AccountDetailsItem.DisplayName import com.wire.android.ui.home.settings.account.AccountDetailsItem.Domain import com.wire.android.ui.home.settings.account.AccountDetailsItem.Email @@ -85,73 +71,70 @@ import com.wire.android.util.ui.sectionWithElements import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -@WireRootDestination +internal enum class MyAccountUpdateKind { + DISPLAY_NAME, + HANDLE, + USER_COLOR, +} + +internal data class MyAccountUpdateNotification( + val requestId: String, + val kind: MyAccountUpdateKind, + val successful: Boolean, +) + @Composable -fun MyAccountScreen( - navigator: Navigator, - changeDisplayNameResultRecipient: ResultRecipient, - changeHandleResultRecipient: ResultRecipient, - changeUserColorResultRecipient: ResultRecipient, - viewModel: MyAccountViewModel = myAccountViewModel(), - deleteAccountViewModel: DeleteAccountViewModel = deleteAccountViewModel() +internal fun MyAccountRouteScreen( + viewModel: MyAccountViewModel, + deleteAccountViewModel: DeleteAccountViewModel, + onNavigateBack: () -> Unit, + onChangeDisplayName: () -> Unit, + onChangeHandle: () -> Unit, + onChangeEmail: () -> Unit, + onChangeUserColor: () -> Unit, + updateNotification: MyAccountUpdateNotification?, ) { val snackbarHostState = LocalSnackbarHostState.current - val scope = rememberCoroutineScope() + val tryAgainMessage = stringResource(id = R.string.error_unknown_message) + val displayNameSuccessMessage = stringResource(id = R.string.settings_myaccount_display_name_updated) + val handleSuccessMessage = stringResource(id = R.string.settings_myaccount_handle_updated) + val userColorSuccessMessage = stringResource(id = R.string.settings_myaccount_user_color_updated) + + LaunchedEffect(updateNotification) { + updateNotification?.let { notification -> + snackbarHostState.showSnackbar( + if (!notification.successful) { + tryAgainMessage + } else { + when (notification.kind) { + MyAccountUpdateKind.DISPLAY_NAME -> displayNameSuccessMessage + MyAccountUpdateKind.HANDLE -> handleSuccessMessage + MyAccountUpdateKind.USER_COLOR -> userColorSuccessMessage + } + } + ) + } + } + with(viewModel.myAccountState) { MyAccountContent( accountDetailItems = mapToUISections( state = this, - navigateToChangeDisplayName = { navigator.navigate(NavigationCommand(ChangeDisplayNameScreenDestination)) }, - navigateToChangeHandle = { navigator.navigate(NavigationCommand(ChangeHandleScreenDestination)) }, - navigateToChangeEmail = { navigator.navigate(NavigationCommand(ChangeEmailScreenDestination)) }, - navigateToChangeColor = { navigator.navigate(NavigationCommand(ChangeUserColorScreenDestination)) } + navigateToChangeDisplayName = onChangeDisplayName, + navigateToChangeHandle = onChangeHandle, + navigateToChangeEmail = onChangeEmail, + navigateToChangeColor = onChangeUserColor, ), - forgotPasswordUrl = this.changePasswordUrl, - canDeleteAccount = viewModel.myAccountState.canDeleteAccount, + forgotPasswordUrl = changePasswordUrl, + canDeleteAccount = canDeleteAccount, onDeleteAccountClicked = deleteAccountViewModel::onDeleteAccountClicked, onDeleteAccountConfirmed = deleteAccountViewModel::onDeleteAccountDialogConfirmed, onDeleteAccountDismissed = deleteAccountViewModel::onDeleteAccountDialogDismissed, startDeleteAccountFlow = deleteAccountViewModel.state.startDeleteAccountFlow, - onNavigateBack = navigator::navigateBack + onNavigateBack = onNavigateBack, ) } - val tryAgainSnackBarMessage = stringResource(id = R.string.error_unknown_message) - val successDisplayNameSnackBarMessage = stringResource(id = R.string.settings_myaccount_display_name_updated) - val successHandleSnackBarMessage = stringResource(id = R.string.settings_myaccount_handle_updated) - val successUserColorSnackBarMessage = stringResource(id = R.string.settings_myaccount_user_color_updated) - HandleNavResult(scope, changeDisplayNameResultRecipient, tryAgainSnackBarMessage, successDisplayNameSnackBarMessage, snackbarHostState) - HandleNavResult(scope, changeHandleResultRecipient, tryAgainSnackBarMessage, successHandleSnackBarMessage, snackbarHostState) - HandleNavResult(scope, changeUserColorResultRecipient, tryAgainSnackBarMessage, successUserColorSnackBarMessage, snackbarHostState) -} - -@Composable -private fun HandleNavResult( - scope: CoroutineScope, - resultRecipient: ResultRecipient, - tryAgainSnackBarMessage: String, - successSnackBarMessage: String, - snackbarHostState: SnackbarHostState -) { - resultRecipient.onNavResult { result -> - when (result) { - is NavResult.Canceled -> { - appLogger.i("Error with receiving navigation back args") - } - - is NavResult.Value -> { - scope.launch { - if (result.value) { - snackbarHostState.showSnackbar(successSnackBarMessage) - } else { - snackbarHostState.showSnackbar(tryAgainSnackBarMessage) - } - } - } - } - } } @Stable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt index 0d67318c18e..df91d4b2432 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/color/ChangeUserColorScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.settings.account.color -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -36,12 +35,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.wire.android.ui.home.settings.changeUserColorViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.BuildConfig.IS_BUBBLE_UI_ENABLED import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.WireDropDown @@ -77,33 +72,28 @@ import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.QualifiedID import kotlinx.collections.immutable.toImmutableList -@WireRootDestination( - style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation -) @Composable -fun ChangeUserColorScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - viewModel: ChangeUserColorViewModel = changeUserColorViewModel() +internal fun ChangeUserColorRouteScreen( + viewModel: ChangeUserColorViewModel, + onBackPressed: () -> Unit, + onCompleted: (Boolean) -> Unit, ) { with(viewModel) { ChangeUserColorContent( state = viewModel.accentState, onSavePressed = ::saveAccentColor, onChangePressed = ::changeAccentColor, - onBackPressed = navigator::navigateBack + onBackPressed = onBackPressed ) HandleActions(actions) { when (it) { ChangeUserColorAction.Success -> { - resultNavigator.setResult(true) - resultNavigator.navigateBack() + onCompleted(true) } ChangeUserColorAction.Failure -> { - resultNavigator.setResult(false) - resultNavigator.navigateBack() + onCompleted(false) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt index 4376a93c3cf..7d91b4f2bde 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.settings.account.displayname -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -41,12 +40,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.wire.android.ui.home.settings.changeDisplayNameViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.R import com.wire.android.model.DisplayNameState -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState.Default @@ -67,25 +62,20 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination( - style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation -) @Composable -fun ChangeDisplayNameScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - viewModel: ChangeDisplayNameViewModel = changeDisplayNameViewModel() +internal fun ChangeDisplayNameRouteScreen( + viewModel: ChangeDisplayNameViewModel, + onBackPressed: () -> Unit, + onCompleted: (Boolean) -> Unit, ) { with(viewModel) { LaunchedEffect(viewModel.displayNameState.completed) { when (viewModel.displayNameState.completed) { DisplayNameState.Completed.Success -> { - resultNavigator.setResult(true) - resultNavigator.navigateBack() + onCompleted(true) } DisplayNameState.Completed.Failure -> { - resultNavigator.setResult(false) - resultNavigator.navigateBack() + onCompleted(false) } DisplayNameState.Completed.None -> Unit // No action needed } @@ -94,7 +84,7 @@ fun ChangeDisplayNameScreen( textState = viewModel.textState, state = viewModel.displayNameState, onContinuePressed = ::saveDisplayName, - onBackPressed = navigator::navigateBack + onBackPressed = onBackPressed ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt index 72441555fcd..690c4b1a290 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.settings.account.email.updateEmail -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -34,16 +33,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.wire.android.ui.home.settings.changeEmailViewModel -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton @@ -55,7 +50,6 @@ import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.textfield.forceLowercase import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.VerifyEmailScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -63,33 +57,32 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.ui.common.R as commonR -@WireRootDestination( - style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation -) @Composable -fun ChangeEmailScreen( - navigator: Navigator, - viewModel: ChangeEmailViewModel = changeEmailViewModel() +internal fun ChangeEmailRouteScreen( + viewModel: ChangeEmailViewModel, + onBackPressed: () -> Unit, + onVerifyEmail: (String) -> Unit, ) { - when (val flowState = viewModel.state.flowState) { - is ChangeEmailState.FlowState.NoChange, - is ChangeEmailState.FlowState.Error.SelfUserNotFound -> navigator.navigateBack() - - is ChangeEmailState.FlowState.Success -> - navigator.navigate( - NavigationCommand( - VerifyEmailScreenDestination(flowState.newEmail), - BackStackMode.REMOVE_CURRENT - ) - ) + val flowState = viewModel.state.flowState + LaunchedEffect(flowState) { + when (flowState) { + is ChangeEmailState.FlowState.NoChange, + is ChangeEmailState.FlowState.Error.SelfUserNotFound -> onBackPressed() - else -> - ChangeEmailContent( - textState = viewModel.textState, - state = viewModel.state, - onBackPressed = navigator::navigateBack, - onSaveClicked = viewModel::onSaveClicked - ) + is ChangeEmailState.FlowState.Success -> onVerifyEmail(flowState.newEmail) + else -> Unit + } + } + if (flowState !is ChangeEmailState.FlowState.NoChange && + flowState !is ChangeEmailState.FlowState.Error.SelfUserNotFound && + flowState !is ChangeEmailState.FlowState.Success + ) { + ChangeEmailContent( + textState = viewModel.textState, + state = viewModel.state, + onBackPressed = onBackPressed, + onSaveClicked = viewModel::onSaveClicked + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailScreen.kt index 570be4e7e88..6307120ba1f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.settings.account.email.verifyEmail -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -35,9 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.ui.home.settings.verifyEmailViewModel import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton @@ -51,21 +48,18 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.stringWithStyledArgs -@WireRootDestination( - navArgs = VerifyEmailNavArgs::class -) @Composable -fun VerifyEmailScreen( - navigator: Navigator, - viewModel: VerifyEmailViewModel = verifyEmailViewModel() +internal fun VerifyEmailRouteScreen( + viewModel: VerifyEmailViewModel, + onBackPressed: () -> Unit, ) { LaunchedEffect(viewModel.state.noChange) { - if (viewModel.state.noChange) navigator.navigateBack() + if (viewModel.state.noChange) onBackPressed() } VerifyEmailContent( state = viewModel.state, - onBackPressed = navigator::navigateBack, + onBackPressed = onBackPressed, onResendVerificationEmailClicked = viewModel::onResendVerificationEmailClicked, newEmail = viewModel.newEmail ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModel.kt index 87194d2527f..9f5212b7f6a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModel.kt @@ -19,10 +19,8 @@ package com.wire.android.ui.home.settings.account.email.verifyEmail import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.feature.user.UpdateEmailUseCase import kotlinx.coroutines.launch import dev.zacsweers.metro.Assisted @@ -30,16 +28,15 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class VerifyEmailViewModel @AssistedInject constructor( private val updateEmail: UpdateEmailUseCase, - @Assisted savedStateHandle: SavedStateHandle + @Assisted navigationArgs: VerifyEmailViewModelArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): VerifyEmailViewModel + fun create(navigationArgs: VerifyEmailViewModelArgs): VerifyEmailViewModel } var state: VerifyEmailState by mutableStateOf(VerifyEmailState()) private set - private val verifyEmailNavArgs: VerifyEmailNavArgs = savedStateHandle.navArgs() - val newEmail: String = verifyEmailNavArgs.newEmail + val newEmail: String = navigationArgs.newEmail fun onResendVerificationEmailClicked() { newEmail.let { state = state.copy(isResendEmailEnabled = false) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModelArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModelArgs.kt new file mode 100644 index 00000000000..61031413d9c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/verifyEmail/VerifyEmailViewModelArgs.kt @@ -0,0 +1,25 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.settings.account.email.verifyEmail + +import com.wire.android.ui.home.settings.VerifyEmailRoute + +data class VerifyEmailViewModelArgs( + val newEmail: String, +) { + init { + require(newEmail.isNotBlank()) { "The email to verify cannot be blank" } + } +} + +internal fun VerifyEmailNavArgs.toViewModelArgs() = VerifyEmailViewModelArgs(newEmail) + +internal fun VerifyEmailRoute.toViewModelArgs() = VerifyEmailViewModelArgs(newEmail) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt index ccb97365992..d7381cec8b3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.settings.account.handle -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -36,11 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.wire.android.ui.home.settings.changeHandleViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.handle.UsernameTextField import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled @@ -56,25 +51,21 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.ui.common.R as commonR -@WireRootDestination( - style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation -) @Composable -fun ChangeHandleScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - viewModel: ChangeHandleViewModel = changeHandleViewModel() +internal fun ChangeHandleRouteScreen( + viewModel: ChangeHandleViewModel, + onBackPressed: () -> Unit, + onCompleted: (Boolean) -> Unit, ) { LaunchedEffect(viewModel.state.isSuccess) { if (viewModel.state.isSuccess) { - resultNavigator.setResult(true) - resultNavigator.navigateBack() + onCompleted(true) } } ChangeHandleContent( textState = viewModel.textState, state = viewModel.state, - onBackPressed = navigator::navigateBack, + onBackPressed = onBackPressed, onSaveClicked = viewModel::onSaveClicked, onErrorDismiss = viewModel::onErrorDismiss ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt index 3c194d8442b..617273693bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/CustomizationScreen.kt @@ -41,8 +41,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination import com.wire.android.ui.common.WireRadioButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.rowitem.SectionHeader @@ -61,10 +59,9 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.sectionWithElements -@WireRootDestination @Composable fun CustomizationScreen( - navigator: Navigator, + onBackPressed: () -> Unit, viewModel: CustomizationViewModel = customizationViewModel() ) { val lazyListState: LazyListState = rememberLazyListState() @@ -72,7 +69,7 @@ fun CustomizationScreen( lazyListState = lazyListState, state = viewModel.state, onThemeOptionChanged = viewModel::selectThemeOption, - onBackPressed = navigator::navigateBack, + onBackPressed = onBackPressed, onEnterToSendClicked = viewModel::selectPressEnterToSendOption, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/AppSettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/AppSettingsScreen.kt index d3728161d56..06da4be658e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/AppSettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/AppSettingsScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.settings.appsettings -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -36,7 +35,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -@WireRootDestination @Composable fun AppSettingsScreen() { Column( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt index b237a76a19f..4ab69240511 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.settings.appsettings.networkSettings -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -28,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.ui.home.settings.networkSettingsViewModel import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.common.scaffold.WireScaffold @@ -38,14 +36,13 @@ import com.wire.android.ui.home.conversations.details.options.GroupConversationO import com.wire.android.ui.home.settings.SwitchState import com.wire.android.ui.theme.WireTheme -@WireRootDestination @Composable fun NetworkSettingsScreen( - navigator: Navigator, + onBackPressed: () -> Unit, networkSettingsViewModel: NetworkSettingsViewModel = networkSettingsViewModel() ) { NetworkSettingsScreenContent( - onBackPressed = navigator::navigateBack, + onBackPressed = onBackPressed, isWebSocketEnabled = networkSettingsViewModel.networkSettingsState.isPersistentWebSocketConnectionEnabled, isEnforcedByMDM = networkSettingsViewModel.networkSettingsState.isEnforcedByMDM, isWebSocketEnforcedByDefault = networkSettingsViewModel.networkSettingsState.isWebSocketEnforcedByDefault, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt index 2cca8725855..212dd8e9ed4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.settings.backup -import com.wire.android.navigation.annotation.app.WireRootDestination import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -43,16 +42,12 @@ import androidx.compose.ui.unit.dp import com.wire.android.ui.home.settings.backupAndRestoreViewModel import com.wire.android.R import com.wire.android.ui.common.R as commonR -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.settings.backup.dialog.create.CreateBackupDialogFlow import com.wire.android.ui.home.settings.backup.dialog.restore.RestoreBackupDialogFlow @@ -63,10 +58,10 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.time.convertTimestampToDateTime import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination @Composable fun BackupAndRestoreScreen( - navigator: Navigator, + onBackPressed: () -> Unit, + onOpenConversations: () -> Unit, viewModel: BackupAndRestoreViewModel = backupAndRestoreViewModel() ) { BackupAndRestoreContent( @@ -80,8 +75,8 @@ fun BackupAndRestoreScreen( onRestoreBackup = viewModel::restorePasswordProtectedBackup, onCancelBackupRestore = viewModel::cancelBackupRestore, onCancelBackupCreation = viewModel::cancelBackupCreation, - onOpenConversations = { navigator.navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) }, - onBackPressed = navigator::navigateBack + onOpenConversations = onOpenConversations, + onBackPressed = onBackPressed, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt index 606a91d61fd..49d18ee66aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home.settings.privacy import com.wire.android.BuildConfig -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -28,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.ui.home.settings.privacySettingsViewModel import com.wire.android.R -import com.wire.android.navigation.Navigator import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.divider.WireDivider @@ -40,10 +38,9 @@ import com.wire.android.ui.home.conversations.details.options.GroupConversationO import com.wire.android.ui.home.settings.SwitchState import com.wire.android.ui.theme.WireTheme -@WireRootDestination @Composable fun PrivacySettingsConfigScreen( - navigator: Navigator, + onBackPressed: () -> Unit, viewModel: PrivacySettingsViewModel = privacySettingsViewModel() ) { with(viewModel) { @@ -59,7 +56,7 @@ fun PrivacySettingsConfigScreen( screenshotCensoringConfig = state.screenshotCensoringConfig, setScreenshotCensoringConfig = ::setScreenshotCensoringConfig, setAnonymousUsageDataEnabled = ::setAnonymousUsageDataEnabled, - onBackPressed = navigator::navigateBack, + onBackPressed = onBackPressed, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/vault/VaultScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/vault/VaultScreen.kt index 398d94e5d5b..e63d32abce0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/vault/VaultScreen.kt @@ -18,12 +18,10 @@ package com.wire.android.ui.home.vault -import com.wire.android.navigation.annotation.app.WireHomeDestination import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.wire.android.ui.common.UnderConstructionScreen -@WireHomeDestination @Composable fun VaultScreen() { UnderConstructionScreen(screenName = "VaultScreen") diff --git a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewItem.kt index fc2410d3005..232dedbe6a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewItem.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.whatsnew -import android.net.Uri import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding @@ -30,12 +29,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.AndroidReleaseNotesDestination -import com.wire.android.navigation.ExternalUriDirection -import com.wire.android.navigation.WelcomeToNewAndroidAppDestination import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.rowitem.RowItemTemplate @@ -103,7 +98,6 @@ fun WhatsNewItem( } sealed class WhatsNewItem( - val direction: Direction, open val id: String, open val title: UIText, open val boldTitle: Boolean = false, @@ -112,7 +106,6 @@ sealed class WhatsNewItem( data object WelcomeToNewAndroidApp : WhatsNewItem( id = "welcome_to_new_android_app", title = UIText.StringResource(R.string.whats_new_welcome_to_new_android_app_label), - direction = WelcomeToNewAndroidAppDestination ) data class AllAndroidReleaseNotes( @@ -120,7 +113,6 @@ sealed class WhatsNewItem( ) : WhatsNewItem( id = id, title = UIText.StringResource(R.string.whats_new_android_release_notes_label), - direction = AndroidReleaseNotesDestination ) data class AndroidReleaseNotes( @@ -134,10 +126,6 @@ sealed class WhatsNewItem( title = title, boldTitle = boldTitle, text = text, - direction = object : ExternalUriDirection { - override val uri: Uri - get() = Uri.parse(url) - } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt index 6f4c2bf8c76..52f0bd84ec2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt @@ -16,9 +16,10 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("MatchingDeclarationName") + package com.wire.android.ui.home.whatsnew -import com.wire.android.navigation.annotation.app.WireHomeDestination import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -27,39 +28,43 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.handleNavigation import com.wire.android.ui.common.R as commonR import com.wire.android.ui.debug.whatsNewViewModel -import com.wire.android.ui.home.HomeStateHolder +import com.wire.android.ui.home.HomeShellState import com.wire.android.util.ui.sectionWithElements import com.wire.android.util.ui.UIText -@WireHomeDestination +internal sealed interface WhatsNewNavigation3Target { + data object Welcome : WhatsNewNavigation3Target + data object AllAndroidReleaseNotes : WhatsNewNavigation3Target + data class ExternalReleaseNote(val url: String) : WhatsNewNavigation3Target +} + +internal fun WhatsNewItem.toNavigation3Target(): WhatsNewNavigation3Target = when (this) { + WhatsNewItem.WelcomeToNewAndroidApp -> WhatsNewNavigation3Target.Welcome + is WhatsNewItem.AllAndroidReleaseNotes -> WhatsNewNavigation3Target.AllAndroidReleaseNotes + is WhatsNewItem.AndroidReleaseNotes -> WhatsNewNavigation3Target.ExternalReleaseNote(url) +} + +/** + * Navigation-neutral What's New renderer used by the Navigation 3 Home shell. + */ @Composable -fun WhatsNewScreen( - homeStateHolder: HomeStateHolder, - whatsNewViewModel: WhatsNewViewModel = whatsNewViewModel() +internal fun WhatsNewScreen( + homeShellState: HomeShellState, + onOpenTarget: (WhatsNewNavigation3Target) -> Unit, + whatsNewViewModel: WhatsNewViewModel = whatsNewViewModel(), ) { - val context = LocalContext.current WhatsNewScreenContent( state = whatsNewViewModel.state, - lazyListState = homeStateHolder.lazyListStateFor(HomeDestination.WhatsNew), - onItemClicked = remember { - { - it.direction.handleNavigation( - context = context, - handleOtherDirection = { homeStateHolder.navigator.navigate(NavigationCommand(it)) } - ) - } - } + lazyListState = homeShellState.lazyListStateFor(HomeDestination.WhatsNew), + onItemClicked = { onOpenTarget(it.toNavigation3Target()) }, ) } @@ -70,7 +75,6 @@ fun WhatsNewScreenContent( modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState() ) { - val context = LocalContext.current val openLinkLabel = stringResource(commonR.string.content_description_open_link_label) LazyColumn( state = lazyListState, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewViewModel.kt index 1638b079b43..5b37736cf98 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewViewModel.kt @@ -27,10 +27,10 @@ import com.prof18.rssparser.RssParser import com.wire.android.R import com.wire.android.di.ApplicationContext import com.wire.android.util.toMediumOnlyDateTime +import dev.zacsweers.metro.Inject import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Locale -import dev.zacsweers.metro.Inject class WhatsNewViewModel @Inject constructor(@ApplicationContext context: Context) : ViewModel() { private val rssParser = RssParser() diff --git a/app/src/main/kotlin/com/wire/android/ui/initialsync/InitialSyncScreen.kt b/app/src/main/kotlin/com/wire/android/ui/initialsync/InitialSyncScreen.kt index 2de41058567..1700dd66598 100644 --- a/app/src/main/kotlin/com/wire/android/ui/initialsync/InitialSyncScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/initialsync/InitialSyncScreen.kt @@ -20,43 +20,20 @@ package com.wire.android.ui.initialsync import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.lifecycleScope -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination -import com.wire.android.navigation.getBaseRoute -import com.wire.android.ui.LocalActivity import com.wire.android.ui.common.SettingUpWireScreenContent import com.wire.android.ui.initialSyncViewModel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -@WireRootDestination @Composable -fun InitialSyncScreen( - navigator: Navigator, - viewModel: InitialSyncViewModel = initialSyncViewModel() +internal fun InitialSyncRouteScreen( + viewModel: InitialSyncViewModel = initialSyncViewModel(), + onSyncCompleted: (shouldMoveToBackground: Boolean) -> Unit, ) { - val activity = LocalActivity.current val syncCompletionState = viewModel.syncCompletionState SettingUpWireScreenContent() LaunchedEffect(syncCompletionState) { syncCompletionState ?: return@LaunchedEffect - - if (syncCompletionState.shouldMoveToBackground) { - activity.lifecycleScope.launch { - navigator.navController.currentBackStackEntryFlow - .map { it.destination.route?.getBaseRoute() } - .first { it == HomeScreenDestination.baseRoute } - activity.moveTaskToBack(false) - } - } - - navigator.navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) + onSyncCompleted(syncCompletionState.shouldMoveToBackground) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt b/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt index 0eae7de2bb6..1ebff96481b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt @@ -78,9 +78,8 @@ fun JoinConversationViaDeepLinkDialog( requirePassword: Boolean, onFlowCompleted: (conversationId: ConversationId?) -> Unit, modifier: Modifier = Modifier, + viewModel: JoinConversationViaCodeViewModel = joinConversationViaCodeViewModel(), ) { - val viewModel = joinConversationViaCodeViewModel() - val isLoading: Boolean by remember { derivedStateOf { viewModel.state is JoinViaDeepLinkDialogState.Loading } } diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/code/NewLoginVerificationCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/code/NewLoginVerificationCodeScreen.kt index d5345da35ab..b399a19f659 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/code/NewLoginVerificationCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/code/NewLoginVerificationCodeScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.newauthentication.login.code -import com.wire.android.navigation.annotation.app.WireNewLoginDestination import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -28,9 +27,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthSlideNavigationAnimation -import com.wire.android.ui.authentication.login.LoginNavArgs import com.wire.android.ui.authentication.login.LoginState import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.authentication.login.email.LoginButton @@ -44,31 +40,24 @@ import com.wire.android.ui.newauthentication.login.NewAuthContainer import com.wire.android.ui.newauthentication.login.NewAuthHeader import com.wire.android.ui.newauthentication.login.NewAuthSubtitle import com.wire.android.ui.newauthentication.login.NewAuthTitle -import com.wire.android.ui.newauthentication.login.password.LoginStateNavigationAndDialogs import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.ui.PreviewMultipleThemes -// has to be navigated to after NewLoginPasswordScreen, otherwise there will be illegal state because it needs to reuse view model from it -@WireNewLoginDestination( - navArgs = LoginNavArgs::class, - style = AuthSlideNavigationAnimation::class, -) @Composable -fun NewLoginVerificationCodeScreen( - navigator: Navigator, - loginEmailViewModel: LoginEmailViewModel, // provided in MainNavHost to reuse from NewLoginPasswordScreen, don't use wireViewModel() +internal fun NewLoginVerificationCodeRouteScreen( + loginEmailViewModel: LoginEmailViewModel, + canNavigateBack: Boolean, + onNavigateBack: () -> Unit, ) { clearAutofillTree() - LoginStateNavigationAndDialogs(loginEmailViewModel, navigator) - LaunchedEffect(loginEmailViewModel) { loginEmailViewModel.autoLoginWhenFullCodeEntered = false } val navigateBack = { loginEmailViewModel.onCodeVerificationBackPress() - navigator.navigateBack() + onNavigateBack() } BackHandler { navigateBack() @@ -80,7 +69,7 @@ fun NewLoginVerificationCodeScreen( isLoading = loginEmailViewModel.loginState.flowState is LoginState.Loading, onResendCode = loginEmailViewModel::onCodeResend, onLoginButtonClick = loginEmailViewModel::login, - canNavigateBack = navigator.navController.previousBackStackEntry != null, // if there is a previous screen to navigate back to + canNavigateBack = canNavigateBack, navigateBack = navigateBack, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginAction.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginAction.kt index 4534da35ed8..79d3aa53b6d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginAction.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginAction.kt @@ -31,8 +31,8 @@ sealed interface NewLoginAction { sealed interface NextStep { data class E2EIEnrollment(val userId: UserId) : NextStep data class TooManyDevices(val userId: UserId) : NextStep - data object InitialSync : NextStep - data object None : NextStep + data class InitialSync(val userId: UserId) : NextStep + data class None(val userId: UserId) : NextStep } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt index 41172927c8e..369d665b4ba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginScreen.kt @@ -35,43 +35,22 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import com.wire.android.ui.authentication.newLoginViewModel -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireNewLoginDestination -import com.wire.android.navigation.style.AuthPopUpNavigationAnimation import com.wire.android.ui.authentication.create.common.ServerTitle -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.authentication.login.LoginErrorDialog import com.wire.android.ui.authentication.login.LoginNavArgs -import com.wire.android.ui.authentication.login.LoginPasswordPath -import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.authentication.login.sso.SsoIdentityChangedDialog import com.wire.android.ui.authentication.login.toLoginDialogErrorData -import com.ramcosta.composedestinations.spec.Direction import com.wire.android.ui.authentication.BackendConfigSuccessContent import com.wire.android.ui.authentication.MissingBackendConfigContent import com.wire.android.ui.common.HandleActions @@ -88,66 +67,20 @@ import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme -import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig -@WireNewLoginDestination( - start = true, - style = AuthPopUpNavigationAnimation::class, - navArgs = LoginNavArgs::class, -) +/** + * Navigation-neutral adapter. Cross-flow effects are exposed as semantic [NewLoginAction] values. + */ @Composable -fun NewLoginScreen( - navigator: Navigator, +internal fun NewLoginRouteScreen( navArgs: LoginNavArgs, - viewModel: NewLoginViewModel = newLoginViewModel() + viewModel: NewLoginViewModel, + canNavigateBack: Boolean, + navigateBack: () -> Unit, + onAction: (NewLoginAction) -> Unit, ) { - val context = LocalContext.current - val currentKeyboardController by rememberUpdatedState(LocalSoftwareKeyboardController.current) - val handleNewLoginAction = { newLoginAction: NewLoginAction -> - when (newLoginAction) { - is NewLoginAction.EmailPassword -> { - val loginNavArgs = LoginNavArgs( - userHandle = PreFilledUserIdentifierType.PreFilled(newLoginAction.userIdentifier), - loginPasswordPath = newLoginAction.loginPasswordPath - ) - navigator.navigate(NavigationCommand(NewLoginPasswordScreenDestination(loginNavArgs))) - } - - is NewLoginAction.CustomConfig -> { - val loginNavArgs = LoginNavArgs( - userHandle = PreFilledUserIdentifierType.PreFilled(newLoginAction.userIdentifier), - loginPasswordPath = LoginPasswordPath(customServerConfig = newLoginAction.customServerConfig) - ) - navigator.navigate(NavigationCommand(NewLoginScreenDestination(loginNavArgs), BackStackMode.CLEAR_WHOLE)) - } - - is NewLoginAction.SSO -> { - currentKeyboardController?.hide() - CustomTabsHelper.launchUrl(context, newLoginAction.url) - } - - is NewLoginAction.Success -> { - navigator.navigate(NavigationCommand(newLoginAction.nextStep.toDestination(), BackStackMode.CLEAR_WHOLE)) - } - - is NewLoginAction.EnterpriseLoginNotSupported -> { - navigator.navigate(NavigationCommand(WelcomeScreenDestination(viewModel.serverConfig), BackStackMode.CLEAR_WHOLE)) - val loginNavArgs = LoginNavArgs( - userHandle = PreFilledUserIdentifierType.PreFilled(userIdentifier = newLoginAction.userIdentifier, editable = true), - loginPasswordPath = LoginPasswordPath(viewModel.serverConfig), - ) - navigator.navigate(NavigationCommand(LoginScreenDestination(loginNavArgs))) - } - } - } - - LaunchedEffect(Unit) { - navigator.navController.currentBackStackEntry?.savedStateHandle - ?.let { viewModel.observeSSOResult(it) } - } - // Handle SSO code auto-login from intent parameter LaunchedEffect(navArgs.ssoCodeAutoLogin) { navArgs.ssoCodeAutoLogin?.let { @@ -188,20 +121,11 @@ fun NewLoginScreen( onBackendConfigLinkEntered = viewModel::onBackendConfigLinkEntered, onBackendConfigSuccessContinue = viewModel::onBackendConfigSuccessContinue, onNoBackendSelected = viewModel::onNoBackendSelected, - canNavigateBack = navigator.navController.previousBackStackEntry != null, // if there is a previous screen to navigate back to - navigateBack = navigator::navigateBack, + canNavigateBack = canNavigateBack, + navigateBack = navigateBack, ) - HandleActions(viewModel.actions, handleNewLoginAction) -} - -private fun NewLoginAction.Success.NextStep.toDestination(): Direction = when (this) { - NewLoginAction.Success.NextStep.None -> HomeScreenDestination - is NewLoginAction.Success.NextStep.E2EIEnrollment -> - E2EIEnrollmentScreenDestination(SessionBackedAuthenticationNavArgs.from(userId)) - NewLoginAction.Success.NextStep.InitialSync -> InitialSyncScreenDestination - is NewLoginAction.Success.NextStep.TooManyDevices -> - RemoveDeviceScreenDestination(SessionBackedAuthenticationNavArgs.from(userId)) + HandleActions(viewModel.actions, onAction) } @OptIn(ExperimentalComposeUiApi::class) diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt index be53be7e8c3..bea4c27d1cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModel.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.appLogger import com.wire.android.config.ServerConfigProvider import com.wire.android.datastore.GlobalDataStore @@ -60,8 +59,8 @@ import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.session.StoreSessionParam import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.EnterpriseLoginResult +import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.LoginRedirectPath import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.auth.sso.FetchSSOSettingsUseCase @@ -76,11 +75,9 @@ import com.wire.kalium.logic.feature.session.DoesValidSessionExistResult import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import java.io.IOException import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory @@ -89,6 +86,7 @@ import dev.zacsweers.metro.Named @Suppress("LongParameterList", "TooManyFunctions") class NewLoginViewModel( + loginNavArgs: LoginNavArgs, private val validateEmailOrSSOCode: ValidateEmailOrSSOCodeUseCase, val coreLogic: CoreLogic, private val savedStateHandle: SavedStateHandle, @@ -97,7 +95,7 @@ class NewLoginViewModel( private val loginExtension: LoginViewModelExtension, private val ssoExtension: LoginSSOViewModelExtension, private val dispatchers: DispatcherProvider, - defaultServerConfig: ServerConfig.Links, + private val defaultServerConfig: ServerConfig.Links, defaultSSOCodeConfig: String, private val isDefaultBackendConfigured: Boolean, private val getServerConfigUseCase: Lazy? = null, @@ -106,6 +104,7 @@ class NewLoginViewModel( @AssistedInject constructor( + @Assisted loginNavArgs: LoginNavArgs, validateEmailOrSSOCode: ValidateEmailOrSSOCodeUseCase, @KaliumCoreLogic coreLogic: CoreLogic, @Assisted savedStateHandle: SavedStateHandle, @@ -120,6 +119,7 @@ class NewLoginViewModel( @Named("isDefaultBackendConfigured") isDefaultBackendConfigured: Boolean, @DefaultWebSocketEnabledByDefault defaultWebSocketEnabledByDefault: Boolean, ) : this( + loginNavArgs, validateEmailOrSSOCode, coreLogic, savedStateHandle, @@ -137,11 +137,10 @@ class NewLoginViewModel( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): NewLoginViewModel + fun create(loginNavArgs: LoginNavArgs, savedStateHandle: SavedStateHandle): NewLoginViewModel } - private val loginNavArgs: LoginNavArgs = savedStateHandle.navArgs() - private val preFilledUserIdentifier: PreFilledUserIdentifierType = loginNavArgs.userHandle ?: PreFilledUserIdentifierType.None + private var preFilledUserIdentifier: PreFilledUserIdentifierType = loginNavArgs.userHandle ?: PreFilledUserIdentifierType.None private var pendingNomadServiceUrl: String? = loginNavArgs.ssoCodeAutoLogin?.nomadServiceUrl private var pendingCookieLabel: String? = loginNavArgs.ssoCodeAutoLogin?.cookieLabel private var pendingSsoSession: StoreSessionParam? = null @@ -166,7 +165,7 @@ class NewLoginViewModel( init { userIdentifierTextState.setTextAndPlaceCursorAtEnd( if (preFilledUserIdentifier is PreFilledUserIdentifierType.PreFilled) { - preFilledUserIdentifier.userIdentifier + (preFilledUserIdentifier as PreFilledUserIdentifierType.PreFilled).userIdentifier } else if (defaultSSOCodeConfig.isNotEmpty() && customServerConfig == null) { defaultSSOCodeConfig.ssoCodeWithPrefix() } else { @@ -210,6 +209,38 @@ class NewLoginViewModel( } } + /** + * Reconciles arguments when Navigation 3 replaces the login root while retaining its flow + * owner. Assisted factory arguments are creation-time only, so a reused flow-scoped + * ViewModel must receive route updates explicitly instead of continuing with stale backend + * or automatic-login state. + */ + fun onNavigationArgumentsChanged(loginNavArgs: LoginNavArgs) { + preFilledUserIdentifier = loginNavArgs.userHandle ?: PreFilledUserIdentifierType.None + pendingNomadServiceUrl = loginNavArgs.ssoCodeAutoLogin?.nomadServiceUrl + pendingCookieLabel = loginNavArgs.ssoCodeAutoLogin?.cookieLabel + + (preFilledUserIdentifier as? PreFilledUserIdentifierType.PreFilled)?.let { + userIdentifierTextState.setTextAndPlaceCursorAtEnd(it.userIdentifier) + } + + val updatedCustomServerConfig = loginNavArgs.loginPasswordPath?.customServerConfig + serverConfig = updatedCustomServerConfig ?: defaultServerConfig + canUseBackend = if (updatedCustomServerConfig != null) { + updatedCustomServerConfig.api.isNotBlank() + } else { + isDefaultServerConfigured + } + CustomTabsHelper.setBackendWebsiteUrl(serverConfig.website) + SupportUrlResolver.setBaseUrl(serverConfig.website) + + when { + !canUseBackend -> updateLoginFlowState(NewLoginFlowState.MissingBackendConfig) + loginNavArgs.showBackendConfigSuccess && updatedCustomServerConfig != null -> + updateLoginFlowState(NewLoginFlowState.BackendConfigSuccess) + } + } + fun onNoBackendSelected() { serverConfig = ServerConfigProvider.EmptyServerConfig canUseBackend = false @@ -419,18 +450,6 @@ class NewLoginViewModel( ) } - fun observeSSOResult(backStackSavedState: SavedStateHandle) { - viewModelScope.launch { - backStackSavedState - .getStateFlow(SSO_LOGIN_RESULT_KEY, null) - .filterNotNull() - .collect { json -> - handleSSOResult(Json.decodeFromString(json)) - backStackSavedState.remove(SSO_LOGIN_RESULT_KEY) - } - } - } - fun handleSSOResult(ssoLoginResult: DeepLinkResult.SSOLogin) { updateLoginFlowState(NewLoginFlowState.Loading) when (ssoLoginResult) { @@ -482,8 +501,8 @@ class NewLoginViewModel( when (result) { is RegisterClientResult.Success -> { when (loginExtension.isInitialSyncCompleted(userId)) { - true -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.None)) - false -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync)) + true -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.None(userId))) + false -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync(userId))) } updateLoginFlowState(NewLoginFlowState.Default) } @@ -539,8 +558,8 @@ class NewLoginViewModel( is RestoreCryptoStateResult.Success -> { withContext(dispatchers.main()) { when (loginExtension.isInitialSyncCompleted(storedUserId)) { - true -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.None)) - false -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync)) + true -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.None(storedUserId))) + false -> sendAction(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync(storedUserId))) } updateLoginFlowState(NewLoginFlowState.Default) } diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/password/NewLoginPasswordScreen.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/password/NewLoginPasswordScreen.kt index 485075677d3..517b2fbfb4d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/password/NewLoginPasswordScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/login/password/NewLoginPasswordScreen.kt @@ -16,9 +16,10 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("MatchingDeclarationName") + package com.wire.android.ui.newauthentication.login.password -import com.wire.android.navigation.annotation.app.WireNewLoginDestination import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -50,13 +51,7 @@ import androidx.compose.ui.text.style.TextDecoration import com.wire.android.BuildConfig import com.wire.android.BuildConfig.ENABLE_NEW_REGISTRATION import com.wire.android.R -import com.wire.android.ui.authentication.loginEmailViewModel -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthSlideNavigationAnimation import com.wire.android.ui.authentication.create.common.ServerTitle -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.authentication.login.DomainClaimedByOrg import com.wire.android.ui.authentication.login.LoginErrorDialog import com.wire.android.ui.authentication.login.LoginNavArgs @@ -86,32 +81,43 @@ import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.textfield.clearAutofillTree import com.wire.android.ui.common.typography import com.wire.android.ui.common.visbility.rememberVisibilityState -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSelectorScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreatePersonalAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginVerificationCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination import com.wire.android.ui.newauthentication.login.NewAuthContainer import com.wire.android.ui.newauthentication.login.NewAuthHeader import com.wire.android.ui.newauthentication.login.NewAuthSubtitle import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.kalium.logic.data.user.UserId + +internal sealed interface NewLoginPasswordScreenAction { + data class Success( + val initialSyncCompleted: Boolean, + val isE2EIRequired: Boolean, + val userId: UserId, + ) : NewLoginPasswordScreenAction + + data class RemoveDevice(val userId: UserId) : NewLoginPasswordScreenAction + data object Canceled : NewLoginPasswordScreenAction + data class VerificationRequired(val navArgs: LoginNavArgs) : NewLoginPasswordScreenAction + data class CreateAccountSelector( + val serverConfig: ServerConfig.Links, + val email: String, + ) : NewLoginPasswordScreenAction + data class CreatePersonalAccount(val serverConfig: ServerConfig.Links) : NewLoginPasswordScreenAction +} -@WireNewLoginDestination( - navArgs = LoginNavArgs::class, - style = AuthSlideNavigationAnimation::class, -) +/** + * Navigation-neutral adapter used by Navigation 3 and the legacy destination. + */ @Composable -fun NewLoginPasswordScreen( - navigator: Navigator, +internal fun NewLoginPasswordRouteScreen( navArgs: LoginNavArgs, - loginEmailViewModel: LoginEmailViewModel = loginEmailViewModel(navArgs) + loginEmailViewModel: LoginEmailViewModel, + canNavigateBack: Boolean, + onAction: (NewLoginPasswordScreenAction) -> Boolean, ) { clearAutofillTree() - LoginStateNavigationAndDialogs(loginEmailViewModel, navigator) + LoginStateNavigationAndDialogs(loginEmailViewModel, onAction) LaunchedEffect(loginEmailViewModel.secondFactorVerificationCodeState) { if (loginEmailViewModel.secondFactorVerificationCodeState.isCodeInputNecessary) { @@ -119,7 +125,7 @@ fun NewLoginPasswordScreen( loginPasswordPath = navArgs.loginPasswordPath, userHandle = PreFilledUserIdentifierType.PreFilled(loginEmailViewModel.userIdentifierTextState.text.toString()) ) - navigator.navigate(NavigationCommand(NewLoginVerificationCodeScreenDestination(verificationCodeNavArgs))) + onAction(NewLoginPasswordScreenAction.VerificationRequired(verificationCodeNavArgs)) } } @@ -133,19 +139,17 @@ fun NewLoginPasswordScreen( onLoginButtonClick = loginEmailViewModel::login, onCreateAccount = { if (ENABLE_NEW_REGISTRATION) { - navigator.navigate( - NavigationCommand( - CreateAccountSelectorScreenDestination( - customServerConfig = loginEmailViewModel.serverConfig, - email = loginEmailViewModel.userIdentifierTextState.text.toString() - ) + onAction( + NewLoginPasswordScreenAction.CreateAccountSelector( + serverConfig = loginEmailViewModel.serverConfig, + email = loginEmailViewModel.userIdentifierTextState.text.toString(), ) ) } else { - navigator.navigate(NavigationCommand(CreatePersonalAccountOverviewScreenDestination(loginEmailViewModel.serverConfig))) + onAction(NewLoginPasswordScreenAction.CreatePersonalAccount(loginEmailViewModel.serverConfig)) } }, - canNavigateBack = navigator.navController.previousBackStackEntry != null, // if there is a previous screen to navigate back to + canNavigateBack = canNavigateBack, navigateBack = loginEmailViewModel::cancelLogin, isCloudAccountCreationPossible = navArgs.loginPasswordPath?.isCloudAccountCreationPossible ?: true, ) @@ -406,38 +410,39 @@ private fun CreateAccountContent(onCreateAccountClicked: () -> Unit, modifier: M } @Composable -fun LoginStateNavigationAndDialogs(viewModel: LoginEmailViewModel, navigator: Navigator) { +internal fun LoginStateNavigationAndDialogs( + viewModel: LoginEmailViewModel, + onAction: (NewLoginPasswordScreenAction) -> Boolean, +) { val state = viewModel.loginState.flowState val emailAlreadyInUseClaimedDomainDialogState = rememberVisibilityState() - val handleLoginStateNavigation: (LoginState) -> Unit = { + val handleLoginStateNavigation: (LoginState) -> Boolean = { when (it) { is LoginState.Success -> { - val destination = when { - it.isE2EIRequired -> E2EIEnrollmentScreenDestination( - SessionBackedAuthenticationNavArgs.from(it.userId) + onAction( + NewLoginPasswordScreenAction.Success( + initialSyncCompleted = it.initialSyncCompleted, + isE2EIRequired = it.isE2EIRequired, + userId = it.userId, ) - it.initialSyncCompleted -> HomeScreenDestination - else -> InitialSyncScreenDestination - } - navigator.navigate(NavigationCommand(destination, BackStackMode.CLEAR_WHOLE)) + ) } is LoginState.Error.TooManyDevicesError -> { - viewModel.clearLoginErrors() - navigator.navigate( - NavigationCommand( - RemoveDeviceScreenDestination(SessionBackedAuthenticationNavArgs.from(it.userId)), - BackStackMode.CLEAR_WHOLE, - ) - ) + val accepted = onAction(NewLoginPasswordScreenAction.RemoveDevice(it.userId)) + if (accepted) { + viewModel.clearLoginErrors() + } + accepted } is LoginState.Canceled -> { - navigator.navigateBack() + onAction(NewLoginPasswordScreenAction.Canceled) } else -> { /* do nothing */ + false } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/newauthentication/welcome/NewWelcomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/newauthentication/welcome/NewWelcomeScreen.kt index c8fe1b92eab..e65d0dbae6c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/newauthentication/welcome/NewWelcomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/newauthentication/welcome/NewWelcomeScreen.kt @@ -20,35 +20,25 @@ package com.wire.android.ui.newauthentication.welcome -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthNoNavigationAnimation -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination -@WireRootDestination(start = true) +/** + * Navigation-neutral adapter used by Navigation 3. + */ @Composable -fun WelcomeChooserScreen( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, +internal fun WelcomeChooserScreen( + onChooseLogin: () -> Unit, ) { LaunchedEffect(Unit) { - val destination = if (loginTypeSelector.canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination() - navigator.navigate(NavigationCommand(destination)) + onChooseLogin() } } // this is completely empty initial screen that allows to show just BackgroundType.Auth until any potential deep link is handled -@WireRootDestination( - style = AuthNoNavigationAnimation::class, -) @Composable fun NewWelcomeEmptyStartScreen() { Box(modifier = Modifier.fillMaxSize()) // empty Box to keep proper bounds of the screen for transition animation to the next screen diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeScreen.kt index a871ebaddf0..9d4c2aa219f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.registration.code -import com.wire.android.navigation.annotation.app.WireCreateAccountDestination import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement @@ -43,15 +42,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview -import com.wire.android.ui.authentication.createAccountVerificationCodeViewModel import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthPopUpNavigationAnimation -import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle -import com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.authentication.verificationcode.ResendCodeText import com.wire.android.ui.common.WireDialog @@ -63,8 +55,6 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.textfield.CodeTextField import com.wire.android.ui.common.textfield.WireTextFieldState -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountUsernameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination import com.wire.android.ui.newauthentication.login.NewAuthContainer import com.wire.android.ui.newauthentication.login.NewAuthHeader import com.wire.android.ui.theme.WireTheme @@ -76,29 +66,19 @@ import com.wire.android.util.dialogErrorStrings import com.wire.kalium.logic.configuration.server.ServerConfig import kotlinx.coroutines.job -@WireCreateAccountDestination( - navArgs = CreateAccountDataNavArgs::class, - style = AuthPopUpNavigationAnimation::class -) @Composable -fun CreateAccountVerificationCodeScreen( - navigator: Navigator, - createAccountCodeVerificationViewModel: CreateAccountVerificationCodeViewModel = - createAccountVerificationCodeViewModel() +internal fun CreateAccountVerificationCodeRouteScreen( + viewModel: CreateAccountVerificationCodeViewModel, + onNavigateBack: () -> Unit, + onSuccess: (com.wire.kalium.logic.data.user.UserId) -> Unit, + onTooManyDevices: (com.wire.kalium.logic.data.user.UserId) -> Unit, ) { - with(createAccountCodeVerificationViewModel) { - fun navigateToUsernameScreen() = navigator.navigate( - NavigationCommand( - CreateAccountUsernameScreenDestination, - BackStackMode.CLEAR_WHOLE - ) - ) - + with(viewModel) { CodeContent( state = codeState, textState = codeTextState, onResendCodePressed = ::resendCode, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, serverConfig = serverConfig ) @@ -116,24 +96,19 @@ fun CreateAccountVerificationCodeScreen( ) } LaunchedEffect(codeState.result) { - if (codeState.result is CreateAccountCodeResult.Success) { - navigateToUsernameScreen() + (codeState.result as? CreateAccountCodeResult.Success)?.let { + onSuccess(it.userId) } val tooManyDevicesError = codeState.result as? CreateAccountCodeResult.Error.TooManyDevicesError if (tooManyDevicesError != null) { clearCodeError() clearCodeField() - navigator.navigate( - NavigationCommand( - RemoveDeviceScreenDestination(SessionBackedAuthenticationNavArgs.from(tooManyDevicesError.userId)), - BackStackMode.CLEAR_WHOLE - ) - ) + onTooManyDevices(tooManyDevicesError.userId) } } BackHandler { if (codeState.loading) return@BackHandler - navigator.navigateBack() + onNavigateBack() } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt index acd876d7fd5..6755ac898e8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt @@ -22,10 +22,8 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.BuildConfig import com.wire.android.analytics.RegistrationAnalyticsManagerUseCase import com.wire.android.di.ClientScopeProvider @@ -53,7 +51,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class CreateAccountVerificationCodeViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val createAccountNavArgs: CreateAccountDataNavArgs, @KaliumCoreLogic private val coreLogic: CoreLogic, private val addAuthenticatedUser: AddAuthenticatedUserUseCase, private val registrationAnalyticsManager: RegistrationAnalyticsManagerUseCase, @@ -63,11 +61,8 @@ class CreateAccountVerificationCodeViewModel @AssistedInject constructor( ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountVerificationCodeViewModel + fun create(createAccountNavArgs: CreateAccountDataNavArgs): CreateAccountVerificationCodeViewModel } - - val createAccountNavArgs: CreateAccountDataNavArgs = savedStateHandle.navArgs() - val serverConfig: ServerConfig.Links = createAccountNavArgs.customServerConfig ?: defaultServerConfig val codeTextState: TextFieldState = TextFieldState() @@ -201,13 +196,13 @@ class CreateAccountVerificationCodeViewModel @AssistedInject constructor( is RegisterClientResult.Success -> { codeState = - codeState.copy(result = CreateAccountCodeResult.Success) + codeState.copy(result = CreateAccountCodeResult.Success(storedUserId)) } is RegisterClientResult.E2EICertificateRequired -> { // TODO codeState = - codeState.copy(result = CreateAccountCodeResult.Success) + codeState.copy(result = CreateAccountCodeResult.Success(storedUserId)) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewState.kt b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewState.kt index 4d45b1e7348..c4bb21dee4a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewState.kt @@ -35,7 +35,7 @@ data class CreateAccountVerificationCodeViewState( sealed interface CreateAccountCodeResult { data object None : CreateAccountCodeResult - data object Success : CreateAccountCodeResult + data class Success(val userId: UserId) : CreateAccountCodeResult sealed class Error : CreateAccountCodeResult { sealed class TextFieldError : Error() { data object InvalidActivationCodeError : TextFieldError() diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt index ccb0fd146a7..85871db9f53 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.registration.details -import com.wire.android.navigation.annotation.app.WireCreateAccountDestination import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -55,11 +54,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle -import com.wire.android.ui.authentication.createAccountDataDetailViewModel import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthPopUpNavigationAnimation import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout @@ -77,7 +72,6 @@ import com.wire.android.ui.common.textfield.DefaultPassword import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountVerificationCodeScreenDestination import com.wire.android.ui.newauthentication.login.NewAuthContainer import com.wire.android.ui.newauthentication.login.NewAuthHeader import com.wire.android.ui.theme.WireTheme @@ -92,35 +86,27 @@ import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig -@WireCreateAccountDestination( - navArgs = CreateAccountDataNavArgs::class, - style = AuthPopUpNavigationAnimation::class -) @Composable -fun CreateAccountDataDetailScreen( - navigator: Navigator, - createAccountDataDetailViewModel: CreateAccountDataDetailViewModel = createAccountDataDetailViewModel() +internal fun CreateAccountDataDetailRouteScreen( + viewModel: CreateAccountDataDetailViewModel, + onNavigateBack: () -> Unit, + onCodeRequested: (CreateAccountDataNavArgs) -> Unit, ) { - with(createAccountDataDetailViewModel) { - fun navigateToCodeScreen() = navigator.navigate( - NavigationCommand( - CreateAccountVerificationCodeScreenDestination( - createAccountNavArgs.copy( - userRegistrationInfo = createAccountNavArgs.userRegistrationInfo.copy( - email = emailTextState.text.toString().trim(), - name = nameTextState.text.toString().trim(), - password = passwordTextState.text.toString(), - teamName = String.EMPTY - ) - ) + with(viewModel) { + fun nextArgs() = + createAccountNavArgs.copy( + userRegistrationInfo = createAccountNavArgs.userRegistrationInfo.copy( + email = emailTextState.text.toString().trim(), + name = nameTextState.text.toString().trim(), + password = passwordTextState.text.toString(), + teamName = String.EMPTY, ) ) - ) - LaunchedEffect(createAccountDataDetailViewModel.detailsState.success) { - if (createAccountDataDetailViewModel.detailsState.success) { - createAccountDataDetailViewModel.onCodeSentHandled() - navigateToCodeScreen() + LaunchedEffect(detailsState.success) { + if (detailsState.success) { + onCodeSentHandled() + onCodeRequested(nextArgs()) } } @@ -135,7 +121,7 @@ fun CreateAccountDataDetailScreen( onPrivacyPolicyAccepted = ::onPrivacyPolicyAccepted, onTermsDialogDismiss = ::onTermsDialogDismiss, onTermsAccept = ::onTermsAccept, - onBackPressed = navigator::navigateBack, + onBackPressed = onNavigateBack, onContinuePressed = ::onDetailsContinue, onErrorDismiss = ::onDetailsErrorDismiss, serverConfig = serverConfig diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt index 3716322d1c1..89b11492c4a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.analytics.RegistrationAnalyticsManagerUseCase @@ -30,7 +29,6 @@ import com.wire.android.di.KaliumCoreLogic import com.wire.android.feature.analytics.model.AnalyticsEvent.RegistrationPersonalAccount import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.common.textfield.textAsFlow -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.ValidateEmailUseCase @@ -46,7 +44,7 @@ import dev.zacsweers.metro.AssistedInject import kotlin.time.Duration.Companion.seconds class CreateAccountDataDetailViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val createAccountNavArgs: CreateAccountDataNavArgs, private val validatePassword: ValidatePasswordUseCase, private val validateEmail: ValidateEmailUseCase, private val globalDataStore: GlobalDataStore, @@ -56,11 +54,8 @@ class CreateAccountDataDetailViewModel @AssistedInject constructor( ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountDataDetailViewModel + fun create(createAccountNavArgs: CreateAccountDataNavArgs): CreateAccountDataDetailViewModel } - - val createAccountNavArgs: CreateAccountDataNavArgs = savedStateHandle.navArgs() - private var withPasswordTries = false val emailTextState: TextFieldState = TextFieldState(createAccountNavArgs.userRegistrationInfo.email) val nameTextState: TextFieldState = TextFieldState() diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorScreen.kt b/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorScreen.kt index 21097051c31..46af19430da 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorScreen.kt @@ -18,10 +18,7 @@ package com.wire.android.ui.registration.selector -import com.wire.android.navigation.annotation.app.WireCreateAccountDestination import android.annotation.SuppressLint -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -41,89 +38,51 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.core.net.toUri -import com.wire.android.ui.authentication.createAccountSelectorViewModel import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthPopUpNavigationAnimation import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.ServerTitle import com.wire.android.ui.authentication.create.common.UserRegistrationInfo -import com.wire.android.ui.authentication.login.LoginNavArgs -import com.wire.android.ui.authentication.login.LoginPasswordPath -import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType import com.wire.android.ui.authentication.login.WireAuthBackgroundLayout import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.EdgeToEdgePreview -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDataDetailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination import com.wire.android.ui.newauthentication.login.NewAuthContainer import com.wire.android.ui.newauthentication.login.NewAuthHeader import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.android.ui.common.R as commonR -@WireCreateAccountDestination( - start = true, - navArgs = CreateAccountSelectorNavArgs::class, - style = AuthPopUpNavigationAnimation::class -) @Composable -fun CreateAccountSelectorScreen( - navigator: Navigator, - viewModel: CreateAccountSelectorViewModel = createAccountSelectorViewModel() +internal fun CreateAccountSelectorRouteScreen( + viewModel: CreateAccountSelectorViewModel, + onPersonalAccountCreation: (CreateAccountDataNavArgs) -> Unit, + onTeamAccountCreation: (String) -> Unit, + onNavigateBack: () -> Unit, ) { - val context = LocalContext.current - fun navigateToEmailScreen() { - val createAccountNavArgs = CreateAccountDataNavArgs( - customServerConfig = viewModel.serverConfig, - userRegistrationInfo = UserRegistrationInfo(viewModel.email) - ) - navigator.navigate(NavigationCommand(CreateAccountDataDetailScreenDestination(createAccountNavArgs))) - } - - val startForResult = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> - val loginNavArgs = LoginNavArgs( - userHandle = PreFilledUserIdentifierType.PreFilled(viewModel.email), - loginPasswordPath = LoginPasswordPath(customServerConfig = viewModel.serverConfig) - ) - navigator.navigate( - NavigationCommand( - NewLoginPasswordScreenDestination(loginNavArgs), - BackStackMode.UPDATE_EXISTED - ) - ) - } - val teamAccountCreationUrl = viewModel.teamAccountCreationUrl + stringResource(R.string.create_account_email_backlink_to_team_suffix_url) - - fun navigateToTeamScreen() { - val customTabsIntent = CustomTabsHelper.buildCustomTabIntent(context) - customTabsIntent.intent.setData(teamAccountCreationUrl.toUri()) - startForResult.launch(customTabsIntent.intent) - } - CreateAccountSelectorContent( customServerLinks = viewModel.serverConfig, - onPersonalAccountCreationClicked = ::navigateToEmailScreen, - onTeamAccountCreationClicked = ::navigateToTeamScreen, - onNavigateBack = navigator::navigateBack, + onPersonalAccountCreationClicked = { + onPersonalAccountCreation( + CreateAccountDataNavArgs( + customServerConfig = viewModel.serverConfig, + userRegistrationInfo = UserRegistrationInfo(viewModel.email), + ) + ) + }, + onTeamAccountCreationClicked = { onTeamAccountCreation(teamAccountCreationUrl) }, + onNavigateBack = onNavigateBack, ) LaunchedEffect(Unit) { diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModel.kt index 029cce21c59..01dbc310a3b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModel.kt @@ -17,11 +17,9 @@ */ package com.wire.android.ui.registration.selector -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.configuration.server.ServerConfig import kotlinx.coroutines.launch import dev.zacsweers.metro.Assisted @@ -30,15 +28,13 @@ import dev.zacsweers.metro.AssistedInject class CreateAccountSelectorViewModel @AssistedInject constructor( private val globalDataStore: GlobalDataStore, - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val navArgs: CreateAccountSelectorNavArgs, defaultServerConfig: ServerConfig.Links ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateAccountSelectorViewModel + fun create(navArgs: CreateAccountSelectorNavArgs): CreateAccountSelectorViewModel } - - val navArgs: CreateAccountSelectorNavArgs = savedStateHandle.navArgs() val serverConfig: ServerConfig.Links = navArgs.customServerConfig ?: defaultServerConfig val email: String = navArgs.email.orEmpty() val teamAccountCreationUrl = serverConfig.teams diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppScreen.kt index 0bb2b7c5977..a217913cf46 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.settings.about -import com.wire.android.navigation.annotation.app.WireRootDestination import android.content.Context import android.widget.Toast import androidx.compose.foundation.ScrollState @@ -36,37 +35,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import com.wire.android.R import com.wire.android.model.Clickable -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.handleNavigation import com.wire.android.ui.common.R as commonR import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.wire.android.ui.debug.aboutThisAppViewModel import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes -@WireRootDestination @Composable -fun AboutThisAppScreen( - navigator: Navigator, - viewModel: AboutThisAppViewModel = aboutThisAppViewModel() +internal fun AboutThisAppRouteScreen( + viewModel: AboutThisAppViewModel, + onBackPressed: () -> Unit, + onItemClicked: (SettingsItem.DirectionItem) -> Unit, ) { - val context = LocalContext.current AboutThisAppContent( state = viewModel.state, - onBackPressed = navigator::navigateBack, - onItemClicked = remember { - { - it.direction.handleNavigation( - context = context, - handleOtherDirection = { navigator.navigate(NavigationCommand(it)) } - ) - } - } + onBackPressed = onBackPressed, + onItemClicked = onItemClicked, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppViewModel.kt index 69b2153d8d1..a71bb79193c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/about/AboutThisAppViewModel.kt @@ -26,8 +26,8 @@ import androidx.lifecycle.viewModelScope import com.wire.android.di.ApplicationContext import com.wire.android.util.AppNameUtil import com.wire.android.util.getGitBuildId -import kotlinx.coroutines.launch import dev.zacsweers.metro.Inject +import kotlinx.coroutines.launch class AboutThisAppViewModel @Inject constructor( @ApplicationContext private val context: Context diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index 468f3b25c20..917d596b01d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.settings.devices -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +44,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.wire.android.ui.home.settings.deviceDetailsViewModel import com.wire.android.BuildConfig import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.model.lastActiveDescription import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialog @@ -71,12 +66,10 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.WireTopAppBarTitle -import com.ramcosta.composedestinations.generated.app.destinations.E2EiCertificateDetailsScreenDestination import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateUI import com.wire.android.ui.home.E2EISuccessDialog import com.wire.android.ui.home.E2EIUpdateErrorWithDismissDialog import com.wire.android.ui.common.rowitem.SectionHeader -import com.wire.android.ui.settings.devices.e2ei.E2EICertificateDetails import com.wire.android.ui.settings.devices.model.DeviceDetailsState import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -102,18 +95,15 @@ import com.wire.kalium.logic.feature.e2ei.X509Identity import com.wire.kalium.logic.feature.e2ei.usecase.FinalizeEnrollmentResult import kotlinx.datetime.Instant -@WireRootDestination( - navArgs = DeviceDetailsNavArgs::class, - style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation -) @Composable -fun DeviceDetailsScreen( - navigator: Navigator, - viewModel: DeviceDetailsViewModel = deviceDetailsViewModel() +internal fun DeviceDetailsRouteScreen( + viewModel: DeviceDetailsViewModel, + onNavigateBack: () -> Unit, + onOpenCertificateDetails: (MLSClientIdentity) -> Unit, ) { when { - viewModel.state.error is RemoveDeviceError.InitError -> navigator.navigateBack() - viewModel.state.deviceRemoved -> navigator.navigateBack() + viewModel.state.error is RemoveDeviceError.InitError -> onNavigateBack() + viewModel.state.deviceRemoved -> onNavigateBack() else -> DeviceDetailsContent( state = viewModel.state, passwordTextState = viewModel.passwordTextState, @@ -121,19 +111,11 @@ fun DeviceDetailsScreen( onRemoveConfirm = viewModel::onRemoveConfirmed, onDialogDismiss = viewModel::onDialogDismissed, onErrorDialogDismiss = viewModel::clearDeleteClientError, - onNavigateBack = navigator::navigateBack, + onNavigateBack = onNavigateBack, onUpdateClientVerification = viewModel::onUpdateVerificationStatus, enrollE2eiCertificate = viewModel::enrollE2EICertificate, handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, - onNavigateToE2eiCertificateDetailsScreen = { - navigator.navigate( - NavigationCommand( - E2EiCertificateDetailsScreenDestination( - E2EICertificateDetails.AfterLoginCertificateDetails(it) - ) - ) - ) - }, + onNavigateToE2eiCertificateDetailsScreen = onOpenCertificateDetails, onEnrollE2EIErrorDismiss = viewModel::hideEnrollE2EICertificateError, onEnrollE2EISuccessDismiss = viewModel::hideEnrollE2EICertificateSuccess, onBreakSession = viewModel::breakSession diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt index b9392f5f9d7..76fd0801d08 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger @@ -30,7 +29,6 @@ import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialogState import com.wire.android.ui.authentication.devices.remove.RemoveDeviceError import com.wire.android.ui.common.textfield.textAsFlow -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.settings.devices.model.DeviceDetailsState import com.wire.kalium.logic.data.client.ClientType import com.wire.kalium.logic.data.client.DeleteClientParam @@ -60,7 +58,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @Suppress("TooManyFunctions", "LongParameterList") class DeviceDetailsViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted navigationArgs: DeviceDetailsViewModelArgs, @CurrentAccount private val currentUserId: UserId, private val deleteClient: DeleteClientUseCase, @@ -75,11 +73,10 @@ class DeviceDetailsViewModel @AssistedInject constructor( ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): DeviceDetailsViewModel + fun create(navigationArgs: DeviceDetailsViewModelArgs): DeviceDetailsViewModel } - private val deviceDetailsNavArgs: DeviceDetailsNavArgs = savedStateHandle.navArgs() - private val deviceId: ClientId = deviceDetailsNavArgs.clientId - private val userId: UserId = deviceDetailsNavArgs.userId + private val deviceId: ClientId = navigationArgs.clientId + private val userId: UserId = navigationArgs.userId val passwordTextState: TextFieldState = TextFieldState() var state: DeviceDetailsState by mutableStateOf( DeviceDetailsState( diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelArgs.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelArgs.kt new file mode 100644 index 00000000000..e0a53e50d45 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelArgs.kt @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.settings.devices + +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.user.UserId + +/** + * Navigation-runtime-neutral arguments consumed by [DeviceDetailsViewModel]. + * + * Both the legacy destination and Navigation 3 map to this type at their boundary. The ViewModel + * therefore has no dependency on a generated destination or a navigation-owned SavedStateHandle. + */ +data class DeviceDetailsViewModelArgs( + val userId: UserId, + val clientId: ClientId, +) + +internal fun DeviceDetailsNavArgs.toViewModelArgs() = DeviceDetailsViewModelArgs( + userId = userId, + clientId = clientId, +) + +internal fun DeviceDetailsRoute.toViewModelArgs() = DeviceDetailsViewModelArgs( + userId = UserId(targetUserId.value, targetUserId.domain), + clientId = ClientId(clientId), +) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt new file mode 100644 index 00000000000..4440e133fb6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt @@ -0,0 +1,254 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.settings.devices + +import androidx.compose.runtime.Composable +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.ui.authentication.clearSessionViewModel +import com.wire.android.ui.authentication.registerDeviceViewModel +import com.wire.android.ui.authentication.removeDeviceViewModel +import com.wire.android.ui.authentication.devices.model.Device +import com.wire.android.ui.authentication.devices.register.RegisterDeviceRoute +import com.wire.android.ui.authentication.devices.register.RegisterDeviceRouteScreen +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRouteScreen +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRouteScreen +import com.wire.android.ui.e2EIEnrollmentViewModel +import com.wire.android.ui.home.settings.deviceDetailsViewModel +import com.wire.android.ui.home.settings.e2eiCertificateDetailsViewModel +import com.wire.android.ui.home.settings.selfDevicesViewModel +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsPayload +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsRoute +import com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsRouteScreen +import com.wire.android.ui.settings.devices.e2ei.E2EICertificateDetails +import com.wire.android.ui.settings.devices.e2ei.toNavigationPayload +import com.wire.android.ui.settings.devices.e2ei.toViewModelArgs +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner + +internal enum class SessionSetupDestination { + HOME, + INITIAL_SYNC, +} + +/** + * Operations whose destinations are owned by another migration batch. + * + * Account-switch callbacks deliberately implement the existing domain interface, so cancelling a + * session-backed authentication flow keeps the same logout/switch ordering without importing a + * generated destination. + */ +internal interface DeviceE2EINavigation3Actions : SwitchAccountActions { + /** + * Used when a restored/deep-linked device route has no parent entry to pop to. + */ + fun exitDeviceManagement() +} + +internal object DeviceE2EINavigation3Contribution { + val resultTypes: List> = emptyList() + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: DeviceE2EINavigation3Actions, + authenticationRouter: AuthenticationNavigation3Router, + ): List = listOf( + deviceE2EINavigation3Entries(runtime, actions, authenticationRouter), + ) +} + +internal fun deviceE2EINavigation3Entries( + runtime: WireNavigation3Runtime, + actions: DeviceE2EINavigation3Actions, + authenticationRouter: AuthenticationNavigation3Router, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + RegisterDeviceNavigation3Entry(route, actions, authenticationRouter) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + RemoveDeviceNavigation3Entry(route, actions, authenticationRouter) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + E2EIEnrollmentNavigation3Entry(route, actions, authenticationRouter) + } + wireEntry { route -> + SelfDevicesNavigation3Entry(runtime, route, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + DeviceDetailsNavigation3Entry(runtime, route, actions) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + E2eiCertificateDetailsNavigation3Entry(runtime, route, actions) + } +} + +@Composable +private fun RegisterDeviceNavigation3Entry( + route: RegisterDeviceRoute, + actions: DeviceE2EINavigation3Actions, + authenticationRouter: AuthenticationNavigation3Router, +) { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + RegisterDeviceRouteScreen( + viewModel = registerDeviceViewModel(flowOwner), + clearSessionViewModel = clearSessionViewModel(flowOwner), + switchAccountActions = actions, + onE2EIRequired = { userId -> + authenticationRouter.registerDeviceToE2EI( + sessionId = userId?.let { WireSessionId(it.value, it.domain) } ?: route.sessionId, + flowId = route.flowId, + ) + }, + onHomeRequired = { + authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.HOME) + }, + onInitialSyncRequired = { + authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.INITIAL_SYNC) + }, + onRemoveDeviceRequired = { + authenticationRouter.registerDeviceToRemoveDevice(route.sessionId, route.flowId) + }, + ) +} + +@Composable +private fun RemoveDeviceNavigation3Entry( + route: RemoveDeviceRoute, + actions: DeviceE2EINavigation3Actions, + authenticationRouter: AuthenticationNavigation3Router, +) { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + RemoveDeviceRouteScreen( + viewModel = removeDeviceViewModel(flowOwner), + clearSessionViewModel = clearSessionViewModel(flowOwner), + switchAccountActions = actions, + onE2EIRequired = { + authenticationRouter.removeDeviceToE2EI(route.sessionId, route.flowId) + }, + onHomeRequired = { + authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.HOME) + }, + onInitialSyncRequired = { + authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.INITIAL_SYNC) + }, + ) +} + +@Composable +private fun E2EIEnrollmentNavigation3Entry( + route: E2EIEnrollmentRoute, + actions: DeviceE2EINavigation3Actions, + authenticationRouter: AuthenticationNavigation3Router, +) { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(route.flowId)) + E2EIEnrollmentRouteScreen( + viewModel = e2EIEnrollmentViewModel(flowOwner), + clearSessionViewModel = clearSessionViewModel(flowOwner), + switchAccountActions = actions, + onInitialSyncRequired = { + authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.INITIAL_SYNC) + }, + onOpenCertificateDetails = { certificate -> + authenticationRouter.navigate( + AuthenticationNavigationTransition.E2EI_TO_CERTIFICATE_DETAILS, + E2eiCertificateDetailsRoute( + sessionId = route.sessionId, + details = E2eiCertificateDetailsPayload.DuringLogin(certificate), + ) + ) + }, + ) +} + +@Composable +private fun SelfDevicesNavigation3Entry( + runtime: WireNavigation3Runtime, + route: SelfDevicesRoute, + actions: DeviceE2EINavigation3Actions, +) { + val viewModel = selfDevicesViewModel() + SelfDevicesRouteScreen( + viewModel = viewModel, + onNavigateBack = runtime.backOrExit(actions), + onDeviceClick = { device -> + runtime.openDeviceDetails(route, viewModel.currentAccountId, device) + }, + ) +} + +@Composable +private fun DeviceDetailsNavigation3Entry( + runtime: WireNavigation3Runtime, + route: DeviceDetailsRoute, + actions: DeviceE2EINavigation3Actions, +) { + DeviceDetailsRouteScreen( + viewModel = deviceDetailsViewModel(route.toViewModelArgs()), + onNavigateBack = runtime.backOrExit(actions), + onOpenCertificateDetails = { identity -> + runtime.navigator.navigate( + WireNavigationCommand( + E2eiCertificateDetailsRoute( + sessionId = route.sessionId, + details = E2EICertificateDetails + .AfterLoginCertificateDetails(identity) + .toNavigationPayload(), + ) + ) + ) + }, + ) +} + +@Composable +private fun E2eiCertificateDetailsNavigation3Entry( + runtime: WireNavigation3Runtime, + route: E2eiCertificateDetailsRoute, + actions: DeviceE2EINavigation3Actions, +) { + E2eiCertificateDetailsRouteScreen( + viewModel = e2eiCertificateDetailsViewModel(route.toViewModelArgs()), + onNavigateBack = runtime.backOrExit(actions), + ) +} + +private fun WireNavigation3Runtime.openDeviceDetails( + source: SelfDevicesRoute, + userId: UserId, + device: Device, +) { + navigator.navigate( + WireNavigationCommand( + DeviceDetailsRoute( + sessionId = source.sessionId, + targetUserId = DeviceTargetUserId(userId.value, userId.domain), + clientId = device.clientId.value, + ) + ) + ) +} + +private fun WireNavigation3Runtime.backOrExit( + actions: DeviceE2EINavigation3Actions, +): () -> Unit = { + if (!navigator.goBack()) actions.exitDeviceManagement() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementLegacyMappers.kt new file mode 100644 index 00000000000..af827f4424b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementLegacyMappers.kt @@ -0,0 +1,39 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.settings.devices + +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId + +internal fun DeviceDetailsNavArgs.toDeviceDetailsRoute( + sessionId: WireSessionId, + entryId: WireNavEntryId = WireNavEntryId.random(), +) = DeviceDetailsRoute( + sessionId = sessionId, + targetUserId = DeviceTargetUserId(userId.value, userId.domain), + clientId = clientId.value, + entryId = entryId, +) + +internal fun DeviceDetailsRoute.toLegacyNavArgs() = DeviceDetailsNavArgs( + userId = UserId(targetUserId.value, targetUserId.domain), + clientId = ClientId(clientId), +) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3.kt new file mode 100644 index 00000000000..d86f14ca177 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3.kt @@ -0,0 +1,73 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.settings.devices + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class SelfDevicesRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/self_devices_screen" + } +} + +/** + * The target user is deliberately separate from [DeviceDetailsRoute.sessionId]. + * + * Device details can be opened for another user's client. The session identity selects the Metro + * graph, while this value selects the user whose client is displayed. + */ +@Serializable +data class DeviceTargetUserId( + val value: String, + val domain: String, +) { + init { + require(value.isNotBlank()) { "A device target user id value cannot be blank" } + require(domain.isNotBlank()) { "A device target user id domain cannot be blank" } + } +} + +@Serializable +data class DeviceDetailsRoute( + override val sessionId: WireSessionId, + val targetUserId: DeviceTargetUserId, + val clientId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + init { + require(clientId.isNotBlank()) { "A device details client id cannot be blank" } + } + + companion object { + const val ROUTE_ID = "app/device_details_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt index 33a8200a25a..41f2b249e18 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.settings.devices -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -33,18 +32,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.wire.android.ui.home.settings.selfDevicesViewModel import androidx.lifecycle.Lifecycle import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.devices.DeviceItem import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.common.ArrowRightIcon import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.ramcosta.composedestinations.generated.app.destinations.DeviceDetailsScreenDestination import com.wire.android.ui.settings.devices.model.SelfDevicesState import com.wire.android.ui.theme.wireColorScheme import com.wire.android.util.ui.sectionWithElements @@ -52,11 +47,11 @@ import com.wire.android.util.lifecycle.rememberLifecycleEvent import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ClientId -@WireRootDestination @Composable -fun SelfDevicesScreen( - navigator: Navigator, - viewModel: SelfDevicesViewModel = selfDevicesViewModel() +internal fun SelfDevicesRouteScreen( + viewModel: SelfDevicesViewModel, + onNavigateBack: () -> Unit, + onDeviceClick: (Device) -> Unit, ) { val lifecycleEvent = rememberLifecycleEvent() LaunchedEffect(lifecycleEvent) { @@ -65,10 +60,8 @@ fun SelfDevicesScreen( SelfDevicesScreenContent( state = viewModel.state, - onNavigateBack = navigator::navigateBack, - onDeviceClick = { - navigator.navigate(NavigationCommand(DeviceDetailsScreenDestination(viewModel.currentAccountId, it.clientId))) - } + onNavigateBack = onNavigateBack, + onDeviceClick = onDeviceClick, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsLegacyMappers.kt new file mode 100644 index 00000000000..8e9b1ed387c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsLegacyMappers.kt @@ -0,0 +1,43 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.settings.devices.e2ei + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId + +internal fun E2EICertificateDetails.toNavigationPayload(): E2eiCertificateDetailsPayload = + when (this) { + is E2EICertificateDetails.DuringLoginCertificateDetails -> + E2eiCertificateDetailsPayload.DuringLogin(certificate) + + is E2EICertificateDetails.AfterLoginCertificateDetails -> + E2eiCertificateDetailsPayload.AfterLogin( + certificate = mlsClientIdentity.x509Identity?.certificate.orEmpty(), + userHandle = mlsClientIdentity.x509Identity?.handle?.handle.orEmpty(), + ) + } + +internal fun E2eiCertificateDetailsScreenNavArgs.toE2eiCertificateDetailsRoute( + sessionId: WireSessionId, + entryId: WireNavEntryId = WireNavEntryId.random(), +) = E2eiCertificateDetailsRoute( + sessionId = sessionId, + details = certificateDetails.toNavigationPayload(), + entryId = entryId, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3.kt new file mode 100644 index 00000000000..be8efe1d26a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3.kt @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.settings.devices.e2ei + +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +/** + * Screen-owned, KMP-safe certificate data. It avoids putting the full Kalium identity model on the + * Navigation 3 back stack when the screen only needs a certificate and optional handle. + */ +@Serializable +sealed interface E2eiCertificateDetailsPayload { + val certificate: String + + @Serializable + data class DuringLogin( + override val certificate: String, + ) : E2eiCertificateDetailsPayload + + @Serializable + data class AfterLogin( + override val certificate: String, + val userHandle: String, + ) : E2eiCertificateDetailsPayload +} + +@Serializable +data class E2eiCertificateDetailsRoute( + override val sessionId: WireSessionId, + val details: E2eiCertificateDetailsPayload, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute, AuthenticationScreenRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/e2ei_certificate_details_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt index 3fa4ca6667a..4dfdc3a7ca3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.settings.devices.e2ei -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding @@ -35,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp import com.wire.android.R -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.bottomsheet.show @@ -47,21 +44,16 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.wire.android.ui.e2eiCertificateDetailsViewModel import com.wire.android.util.copyLinkToClipboard import com.wire.android.util.createPemFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@WireRootDestination( - navArgs = E2eiCertificateDetailsScreenNavArgs::class, - style = PopUpNavigationAnimation::class, -) @Composable -fun E2eiCertificateDetailsScreen( - navigator: Navigator, - e2eiCertificateDetailsViewModel: E2eiCertificateDetailsViewModel = e2eiCertificateDetailsViewModel() +internal fun E2eiCertificateDetailsRouteScreen( + viewModel: E2eiCertificateDetailsViewModel, + onNavigateBack: () -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -71,7 +63,7 @@ fun E2eiCertificateDetailsScreen( WireScaffold( topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = navigator::navigateBack, + onNavigationPressed = onNavigateBack, title = stringResource(R.string.e2ei_certificate_details_screen_title), navigationIconType = NavigationIconType.Back(), actions = { @@ -86,7 +78,7 @@ fun E2eiCertificateDetailsScreen( ) { val clipboardManager = LocalClipboardManager.current - with(e2eiCertificateDetailsViewModel) { + with(viewModel) { val copiedToClipboardString = stringResource(id = R.string.e2ei_certificate_details_certificate_copied_to_clipboard) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt index 5371d8eae8d..33772018d24 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt @@ -19,25 +19,21 @@ package com.wire.android.ui.settings.devices.e2ei import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.fileDateTime import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.util.DateTimeUtil import kotlinx.coroutines.launch class E2eiCertificateDetailsViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted private val navigationArgs: E2eiCertificateDetailsViewModelArgs, private val getSelfUser: GetSelfUserUseCase, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): E2eiCertificateDetailsViewModel + fun create(navigationArgs: E2eiCertificateDetailsViewModelArgs): E2eiCertificateDetailsViewModel } - private val navArgs: E2eiCertificateDetailsScreenNavArgs = - savedStateHandle.navArgs() private var selfUserHandle: String? = null init { getSelfUserHandle() @@ -48,18 +44,11 @@ class E2eiCertificateDetailsViewModel @AssistedInject constructor( } } fun getCertificate() = - when (navArgs.certificateDetails) { - is E2EICertificateDetails.DuringLoginCertificateDetails -> - navArgs.certificateDetails.certificate - is E2EICertificateDetails.AfterLoginCertificateDetails -> - navArgs.certificateDetails.mlsClientIdentity.x509Identity?.certificate ?: "" - } + navigationArgs.certificate fun userHandle() = - when (navArgs.certificateDetails) { - is E2EICertificateDetails.DuringLoginCertificateDetails -> - selfUserHandle - is E2EICertificateDetails.AfterLoginCertificateDetails -> - navArgs.certificateDetails.mlsClientIdentity.x509Identity?.handle?.handle ?: "" + when (val args = navigationArgs) { + is E2eiCertificateDetailsViewModelArgs.DuringLogin -> selfUserHandle + is E2eiCertificateDetailsViewModelArgs.AfterLogin -> args.userHandle } fun getCertificateName(): String { val date = DateTimeUtil.currentInstant().fileDateTime() diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModelArgs.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModelArgs.kt new file mode 100644 index 00000000000..cd76962524a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModelArgs.kt @@ -0,0 +1,55 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.settings.devices.e2ei + +/** + * The certificate details actually consumed by the screen ViewModel. + * + * The legacy destination may carry a full MLS identity, while Navigation 3 persists only these + * stable strings. Keeping that conversion outside the ViewModel makes its ownership independent + * of either navigation runtime. + */ +sealed interface E2eiCertificateDetailsViewModelArgs { + val certificate: String + + data class DuringLogin( + override val certificate: String, + ) : E2eiCertificateDetailsViewModelArgs + + data class AfterLogin( + override val certificate: String, + val userHandle: String, + ) : E2eiCertificateDetailsViewModelArgs +} + +internal fun E2eiCertificateDetailsScreenNavArgs.toViewModelArgs(): E2eiCertificateDetailsViewModelArgs = + when (val details = certificateDetails) { + is E2EICertificateDetails.DuringLoginCertificateDetails -> + E2eiCertificateDetailsViewModelArgs.DuringLogin(details.certificate) + + is E2EICertificateDetails.AfterLoginCertificateDetails -> + E2eiCertificateDetailsViewModelArgs.AfterLogin( + certificate = details.mlsClientIdentity.x509Identity?.certificate.orEmpty(), + userHandle = details.mlsClientIdentity.x509Identity?.handle?.handle.orEmpty(), + ) + } + +internal fun E2eiCertificateDetailsRoute.toViewModelArgs(): E2eiCertificateDetailsViewModelArgs = + when (val details = details) { + is E2eiCertificateDetailsPayload.DuringLogin -> + E2eiCertificateDetailsViewModelArgs.DuringLogin(details.certificate) + + is E2eiCertificateDetailsPayload.AfterLogin -> + E2eiCertificateDetailsViewModelArgs.AfterLogin( + certificate = details.certificate, + userHandle = details.userHandle, + ) + } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index f75d663318a..0d1faefada8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.sharing +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -56,19 +57,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.model.ImageAsset import com.wire.android.model.SnackBarMessage import com.wire.android.model.UserAvatarData -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.annotation.app.WireRootDestination import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout @@ -86,14 +79,13 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchTopBar import com.wire.android.ui.home.FeatureFlagState -import com.wire.android.ui.home.featureFlagNotificationViewModel import com.wire.android.ui.home.conversations.AssetTooLargeDialog -import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.checkAssetRestrictionsViewModel import com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel import com.wire.android.ui.home.conversations.media.RestrictionCheckState import com.wire.android.ui.home.conversations.media.preview.AssetTilePreview import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.kalium.logic.data.id.ConversationId import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.ui.home.conversations.selfdeletion.selfDeletionMenuItems import com.wire.android.ui.home.conversationslist.common.ConversationList @@ -102,7 +94,6 @@ import com.wire.android.ui.home.conversationslist.common.previewConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration import com.wire.android.ui.home.newconversation.common.SendContentButton -import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel import com.wire.android.ui.importMediaAuthenticatedViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -123,54 +114,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import okio.Path.Companion.toPath -@WireRootDestination(navArgs = ImportMediaNavArgs::class) @Composable -fun ImportMediaScreen( - navArgs: ImportMediaNavArgs, - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, - featureFlagNotificationViewModel: FeatureFlagNotificationViewModel = featureFlagNotificationViewModel(), -) { - val navigateBack = when (navArgs.source) { - ImportSource.EXTERNAL_SHARE -> navigator.finish - ImportSource.INTERNAL_SHARE -> navigator::navigateBack - } - - when (val fileSharingRestrictedState = featureFlagNotificationViewModel.featureFlagState.isFileSharingState) { - FeatureFlagState.FileSharingState.Loading -> { - ImportMediaLoadingContent( - navigateBack = navigateBack - ) - } - - FeatureFlagState.FileSharingState.NoUser -> { - ImportMediaLoggedOutContent( - fileSharingRestrictedState = fileSharingRestrictedState, - navigateBack = navigateBack, - openWireAction = { - val destination = if (loginTypeSelector.canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination() - navigator.navigate(NavigationCommand(destination, BackStackMode.CLEAR_WHOLE)) - } - ) - } - - FeatureFlagState.FileSharingState.DisabledByTeam, - FeatureFlagState.FileSharingState.AllowAll, - is FeatureFlagState.FileSharingState.AllowSome -> { - ImportMediaAuthenticatedContent( - navArgs = navArgs, - navigator = navigator, - isRestrictedInTeam = fileSharingRestrictedState == FeatureFlagState.FileSharingState.DisabledByTeam, - navigateBack = navigateBack, - ) - } - } - - BackHandler { navigateBack() } -} - -@Composable -private fun ImportMediaLoadingContent(navigateBack: () -> Unit) { +internal fun ImportMediaLoadingContent(navigateBack: () -> Unit) { WireScaffold( topBar = { WireCenterAlignedTopAppBar( @@ -201,11 +146,11 @@ private fun ImportMediaLoadingContent(navigateBack: () -> Unit) { } @Composable -private fun ImportMediaAuthenticatedContent( - navArgs: ImportMediaNavArgs, - navigator: Navigator, +internal fun ImportMediaAuthenticatedContent( isRestrictedInTeam: Boolean, navigateBack: () -> Unit, + onConversationReady: (ConversationId, List, String?) -> Unit, + internalAssetUris: List = emptyList(), checkAssetRestrictionsViewModel: CheckAssetRestrictionsViewModel = checkAssetRestrictionsViewModel(), importMediaViewModel: ImportMediaAuthenticatedViewModel = importMediaAuthenticatedViewModel(), ) { @@ -213,24 +158,17 @@ private fun ImportMediaAuthenticatedContent( ImportMediaRestrictedContent( importMediaAuthenticatedState = importMediaViewModel.importMediaState, avatarAsset = null, - navigateBack = navigateBack + navigateBack = navigateBack, ) } else { LaunchedEffect(checkAssetRestrictionsViewModel.state) { with(checkAssetRestrictionsViewModel.state) { if (this is RestrictionCheckState.Success) { importMediaViewModel.importMediaState.selectedConversationItem.firstOrNull()?.let { conversationItem -> - navigator.navigate( - NavigationCommand( - ConversationScreenDestination( - ConversationNavArgs( - conversationId = conversationItem, - pendingBundles = ArrayList(this.assetBundleList), - pendingTextBundle = importMediaViewModel.importMediaState.importedText, - ) - ), - BackStackMode.REMOVE_CURRENT_AND_REPLACE - ), + onConversationReady( + conversationItem, + this.assetBundleList, + importMediaViewModel.importMediaState.importedText, ) } } @@ -258,14 +196,13 @@ private fun ImportMediaAuthenticatedContent( val context = LocalContext.current with(importMediaViewModel.importMediaState) { - LaunchedEffect(navArgs.source, navArgs.internalAssetUriList) { + LaunchedEffect(internalAssetUris) { if (!hasImportedContent()) { - when (navArgs.source) { - ImportSource.EXTERNAL_SHARE -> context.getActivity() + if (internalAssetUris.isNotEmpty()) { + importMediaViewModel.handleReceivedDataFromInternalShare(internalAssetUris) + } else { + context.getActivity() ?.let { activity -> importMediaViewModel.handleReceivedDataFromSharingIntent(activity) } - - ImportSource.INTERNAL_SHARE -> - importMediaViewModel.handleReceivedDataFromInternalShare(navArgs.internalAssetUriList) } } } @@ -273,8 +210,6 @@ private fun ImportMediaAuthenticatedContent( } } -private fun ImportMediaNavArgs.isInternalShare(): Boolean = internalAssetUriList.isNotEmpty() - @Composable fun ImportMediaRestrictedContent( importMediaAuthenticatedState: ImportMediaAuthenticatedState, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileLegacyNavigationMappers.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileLegacyNavigationMappers.kt new file mode 100644 index 00000000000..1c294bfb418 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileLegacyNavigationMappers.kt @@ -0,0 +1,23 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile + +import com.wire.kalium.logic.data.id.QualifiedID + +internal fun QualifiedID.toUserProfileQualifiedId() = UserProfileQualifiedId( + value = value, + domain = domain, +) + +internal fun UserProfileQualifiedId.toQualifiedId() = QualifiedID( + value = value, + domain = domain, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Entries.kt new file mode 100644 index 00000000000..967689d0b19 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Entries.kt @@ -0,0 +1,320 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.home.conversations.ConversationAuxId +import com.wire.android.ui.home.conversations.ConversationFoldersNavigation3ResultType +import com.wire.android.ui.home.conversations.ConversationFoldersRoute +import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel +import com.wire.android.ui.miscViewModel +import com.wire.android.ui.home.settings.otherUserProfileScreenViewModel +import com.wire.android.ui.home.settings.selfQRCodeViewModel +import com.wire.android.ui.home.settings.serviceDetailsViewModel +import com.wire.android.ui.home.settings.settingsViewModel +import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerResult +import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerResultContract +import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerRoute +import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerScreen +import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel +import com.wire.android.ui.userprofile.other.ConnectionRequestIgnoredResult +import com.wire.android.ui.userprofile.other.ConnectionRequestIgnoredResultContract +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.android.ui.userprofile.other.OtherUserProfileRouteScreen +import com.wire.android.ui.userprofile.other.toViewModelArgs as toOtherUserProfileViewModelArgs +import com.wire.android.ui.userprofile.qr.SelfQRCodeScreen +import com.wire.android.ui.userprofile.qr.SelfQrCodeRoute +import com.wire.android.ui.userprofile.qr.toViewModelArgs as toSelfQrCodeViewModelArgs +import com.wire.android.ui.userprofile.self.SelfUserProfileRoute +import com.wire.android.ui.userprofile.self.SelfUserProfileRouteScreen +import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel +import com.wire.android.ui.userprofile.service.ServiceDetailsRoute +import com.wire.android.ui.userprofile.service.ServiceDetailsScreen +import com.wire.android.ui.userprofile.service.toViewModelArgs as toServiceDetailsViewModelArgs +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireNavigationCommand + +internal val AvatarPickerNavigation3ResultType = WireNavigation3ResultType( + contract = AvatarPickerResultContract, + serializer = AvatarPickerResult.serializer(), +) + +internal val ConnectionRequestIgnoredNavigation3ResultType = WireNavigation3ResultType( + contract = ConnectionRequestIgnoredResultContract, + serializer = ConnectionRequestIgnoredResult.serializer(), +) + +/** + * Cross-batch exits used by the user-profile entries. + * + * Keeping these operations semantic prevents the contribution from importing generated + * destinations while their owning batches are still on the legacy host. + */ +internal interface UserProfileNavigation3Actions { + fun exitUserProfile() + fun openAppSettings() + fun openAddAccount() + fun openTeamMigration(wasMigrationDotActive: Boolean) + fun openAccountDetails() + fun switchedToAnotherAccount() + fun noOtherAccountToSwitch() + fun openConversation(conversationId: UserProfileQualifiedId) + fun openDeviceDetails(userId: UserProfileQualifiedId, clientId: String) + fun searchConversationMessages(conversationId: UserProfileQualifiedId) + fun openConversationMedia(conversationId: UserProfileQualifiedId) + fun openConversationDebugMenu(conversationId: UserProfileQualifiedId) +} + +internal object UserProfileNavigation3Contribution { + val resultTypes: List> = listOf( + AvatarPickerNavigation3ResultType, + ConnectionRequestIgnoredNavigation3ResultType, + ) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: UserProfileNavigation3Actions, + ): List = listOf( + userProfileNavigation3Entries(runtime, actions) + ) +} + +internal fun userProfileNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: UserProfileNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.Slide) { + AvatarPickerNavigation3Entry(runtime) + } + wireEntry(presentation = WireEntryPresentation.Slide) { + SelfQrCodeNavigation3Entry(runtime, it, actions) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + SelfUserProfileNavigation3Entry(runtime, it, actions) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + OtherUserProfileNavigation3Entry(runtime, it, actions) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + ServiceDetailsNavigation3Entry(runtime, it, actions) + } +} + +@Composable +private fun AvatarPickerNavigation3Entry( + runtime: WireNavigation3Runtime, +) { + val viewModel = settingsViewModel() + AvatarPickerScreen( + viewModel = viewModel, + onNavigateBack = { runtime.navigator.goBack() }, + onAvatarSelected = { assetId -> + if ( + !runtime.completeCurrentAndPop( + AvatarPickerNavigation3ResultType, + WireNavResult.Value(AvatarPickerResult(assetId)), + ) + ) { + runtime.navigator.goBack() + } + }, + ) +} + +@Composable +private fun SelfQrCodeNavigation3Entry( + runtime: WireNavigation3Runtime, + route: SelfQrCodeRoute, + actions: UserProfileNavigation3Actions, +) { + SelfQRCodeScreen( + viewModel = selfQRCodeViewModel(route.toSelfQrCodeViewModelArgs()), + onNavigateBack = { + if (!runtime.navigator.goBack()) actions.exitUserProfile() + }, + ) +} + +@Composable +private fun SelfUserProfileNavigation3Entry( + runtime: WireNavigation3Runtime, + route: SelfUserProfileRoute, + actions: UserProfileNavigation3Actions, +) { + val viewModel = settingsViewModel() + val switchAccountActions = remember(route.entryId.value, actions) { + UserProfileSwitchAccountActions(actions) + } + var avatarRequestIdValue by rememberSaveable(route.entryId.value) { + mutableStateOf(null) + } + + val currentEntryId = runtime.navigator.currentRoute?.entryId + LaunchedEffect(avatarRequestIdValue, currentEntryId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + val requestId = avatarRequestIdValue?.let(::WireNavResultRequestId) + ?: return@LaunchedEffect + when ( + val result = runtime.consumeResult( + requestId, + AvatarPickerNavigation3ResultType, + ) + ) { + is WireNavResult.Value -> { + result.value.assetId?.let(viewModel::reloadNewPickedAvatar) + avatarRequestIdValue = null + } + + WireNavResult.Canceled -> avatarRequestIdValue = null + null -> Unit + } + } + + SelfUserProfileRouteScreen( + viewModelSelf = viewModel, + legalHoldRequestedViewModel = miscViewModel(), + onClose = { + if (!runtime.navigator.goBack()) actions.exitUserProfile() + }, + onLogout = { viewModel.logout(it, switchAccountActions) }, + onChangeAvatar = { + avatarRequestIdValue = runtime.navigateForResult( + AvatarPickerRoute(sessionId = route.sessionId), + AvatarPickerNavigation3ResultType, + )?.value + }, + onEditProfile = actions::openAppSettings, + onAddAccount = actions::openAddAccount, + onSwitchAccount = { viewModel.switchAccount(it, switchAccountActions) }, + onOpenQrCode = { handle, isTeamMember -> + runtime.navigator.navigate( + WireNavigationCommand( + SelfQrCodeRoute( + sessionId = route.sessionId, + userHandle = handle, + isTeamMember = isTeamMember, + ) + ) + ) + }, + onCreateTeam = actions::openTeamMigration, + onOpenAccountDetails = actions::openAccountDetails, + ) +} + +@Composable +private fun OtherUserProfileNavigation3Entry( + runtime: WireNavigation3Runtime, + route: OtherUserProfileRoute, + actions: UserProfileNavigation3Actions, +) { + var folderRequestIdValue by rememberSaveable(route.entryId.value) { + mutableStateOf(null) + } + val snackbarHostState = LocalSnackbarHostState.current + val currentEntryId = runtime.navigator.currentRoute?.entryId + LaunchedEffect(folderRequestIdValue, currentEntryId) { + if (currentEntryId != route.entryId) return@LaunchedEffect + val requestId = folderRequestIdValue?.let(::WireNavResultRequestId) + ?: return@LaunchedEffect + when (val result = runtime.consumeResult(requestId, ConversationFoldersNavigation3ResultType)) { + is WireNavResult.Value -> { + // The snackbar is the durable fallback; no pre-recreation callback is required. + snackbarHostState.showSnackbar(result.value.message) + folderRequestIdValue = null + } + WireNavResult.Canceled -> folderRequestIdValue = null + null -> Unit + } + } + + OtherUserProfileRouteScreen( + viewModel = otherUserProfileScreenViewModel( + route.toOtherUserProfileViewModelArgs() + ), + onNavigateBack = { + if (!runtime.navigator.goBack()) actions.exitUserProfile() + }, + onIgnoredConnectionRequest = { userName -> + if ( + !runtime.completeCurrentAndPop( + ConnectionRequestIgnoredNavigation3ResultType, + WireNavResult.Value(ConnectionRequestIgnoredResult(userName)), + ) + ) { + runtime.navigator.goBack() + } + }, + onOpenConversation = { actions.openConversation(it.toNavigationId()) }, + onOpenDeviceDetails = { + actions.openDeviceDetails(route.targetUserId, it.clientId.value) + }, + onSearchConversationMessages = { + actions.searchConversationMessages(it.toNavigationId()) + }, + onOpenConversationMedia = { + actions.openConversationMedia(it.toNavigationId()) + }, + onMoveToFolder = { conversationId, _ -> + folderRequestIdValue = runtime.navigateForResult( + ConversationFoldersRoute( + route.sessionId, + ConversationAuxId(conversationId.value, conversationId.domain), + "", + null, + ), + ConversationFoldersNavigation3ResultType, + )?.value + }, + onOpenConversationDebugMenu = { + actions.openConversationDebugMenu(it.toNavigationId()) + }, + ) +} + +@Composable +private fun ServiceDetailsNavigation3Entry( + runtime: WireNavigation3Runtime, + route: ServiceDetailsRoute, + actions: UserProfileNavigation3Actions, +) { + ServiceDetailsScreen( + viewModel = serviceDetailsViewModel(route.toServiceDetailsViewModelArgs()), + navigateBack = { + if (!runtime.navigator.goBack()) actions.exitUserProfile() + }, + openConversation = { actions.openConversation(it.toNavigationId()) }, + ) +} + +private class UserProfileSwitchAccountActions( + private val actions: UserProfileNavigation3Actions, +) : SwitchAccountActions { + override fun switchedToAnotherAccount() = actions.switchedToAnotherAccount() + override fun noOtherAccountToSwitch() = actions.noOtherAccountToSwitch() +} + +private fun ConversationId.toNavigationId() = UserProfileQualifiedId(value, domain) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileQualifiedId.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileQualifiedId.kt new file mode 100644 index 00000000000..5614e123c8a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/UserProfileQualifiedId.kt @@ -0,0 +1,38 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile + +import kotlinx.serialization.Serializable + +/** + * Navigation-owned representation of a qualified Wire identifier. + * + * Keeping Kalium models out of the route contract makes the back stack portable to every + * Navigation 3 host while the pure mappers retain parity with the legacy destinations. + */ +@Serializable +data class UserProfileQualifiedId( + val value: String, + val domain: String, +) { + init { + require(value.isNotBlank()) { "A qualified id value cannot be blank" } + require(domain.isNotBlank()) { "A qualified id domain cannot be blank" } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt index 12b3124e70c..aba7688c799 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.userprofile.avatarpicker -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,12 +34,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.core.net.toUri -import com.wire.android.ui.home.settings.avatarPickerViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.R import com.wire.android.ui.common.R as commonR -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.ArrowRightIcon import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem import com.wire.android.ui.common.bottomsheet.MenuItemIcon @@ -66,14 +61,11 @@ import com.wire.android.util.ui.PreviewMultipleThemesForLandscape import com.wire.android.util.ui.PreviewMultipleThemesForPortrait import com.wire.android.util.ui.PreviewMultipleThemesForSquare -@WireRootDestination( - style = SlideNavigationAnimation::class, // default should be SlideNavigationAnimation -) @Composable -fun AvatarPickerScreen( - navigator: Navigator, - resultNavigator: ResultBackNavigator, - viewModel: AvatarPickerViewModel = avatarPickerViewModel() +internal fun AvatarPickerScreen( + viewModel: AvatarPickerViewModel, + onNavigateBack: () -> Unit, + onAvatarSelected: (String?) -> Unit, ) { val permissionPermanentlyDeniedDialogState = rememberVisibilityState() @@ -115,15 +107,14 @@ fun AvatarPickerScreen( LaunchedEffect(viewModel.pictureState) { (viewModel.pictureState as? PictureState.Completed)?.let { - resultNavigator.setResult(it.assetId) - resultNavigator.navigateBack() + onAvatarSelected(it.assetId) } } AvatarPickerContent( pictureState = viewModel.pictureState, state = state, - onCloseClick = navigator::navigateBack, + onCloseClick = onNavigateBack, onCancelClick = viewModel::loadInitialAvatarState, onSaveClick = viewModel::uploadNewPickedAvatar ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerLegacyNavigationMappers.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerLegacyNavigationMappers.kt new file mode 100644 index 00000000000..92b3273610e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerLegacyNavigationMappers.kt @@ -0,0 +1,15 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.avatarpicker + +internal fun String?.toAvatarPickerResult() = AvatarPickerResult(assetId = this) + +internal fun AvatarPickerResult.toLegacyResult(): String? = assetId diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3.kt new file mode 100644 index 00000000000..12d2ed0d0a6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3.kt @@ -0,0 +1,52 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.avatarpicker + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class AvatarPickerRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/avatar_picker_screen" + } +} + +/** + * A wrapper deliberately preserves the difference between a successful null value and + * Navigation 3 cancellation. + */ +@Serializable +data class AvatarPickerResult( + val assetId: String?, +) + +internal val AvatarPickerResultContract = WireNavResultContract( + WireNavResultContractId("user-profile.avatar-picker") +) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileLegacyNavigationMappers.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileLegacyNavigationMappers.kt new file mode 100644 index 00000000000..7476b042ab4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileLegacyNavigationMappers.kt @@ -0,0 +1,40 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.other + +import com.wire.android.ui.userprofile.toQualifiedId +import com.wire.android.ui.userprofile.toUserProfileQualifiedId +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId + +internal fun OtherUserProfileNavArgs.toOtherUserProfileRoute( + sessionId: WireSessionId, + entryId: WireNavEntryId = WireNavEntryId.random(), +) = OtherUserProfileRoute( + sessionId = sessionId, + targetUserId = userId.toUserProfileQualifiedId(), + groupConversationId = groupConversationId?.toUserProfileQualifiedId(), + entryId = entryId, +) + +internal fun OtherUserProfileRoute.toLegacyNavArgs() = OtherUserProfileNavArgs( + userId = targetUserId.toQualifiedId(), + groupConversationId = groupConversationId?.toQualifiedId(), +) + +internal fun OtherUserProfileNavArgs.toViewModelArgs() = OtherUserProfileViewModelArgs( + targetUserId = userId.toUserProfileQualifiedId(), + groupConversationId = groupConversationId?.toUserProfileQualifiedId(), +) + +internal fun String.toConnectionRequestIgnoredResult() = ConnectionRequestIgnoredResult(this) + +internal fun ConnectionRequestIgnoredResult.toLegacyResult(): String = userName diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3.kt new file mode 100644 index 00000000000..bfba4fc923a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3.kt @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.other + +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OtherUserProfileViewModelArgs( + val targetUserId: UserProfileQualifiedId, + val groupConversationId: UserProfileQualifiedId? = null, +) + +@Serializable +data class OtherUserProfileRoute( + override val sessionId: WireSessionId, + val targetUserId: UserProfileQualifiedId, + val groupConversationId: UserProfileQualifiedId? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/other_user_profile_screen" + } +} + +internal fun OtherUserProfileRoute.toViewModelArgs() = OtherUserProfileViewModelArgs( + targetUserId = targetUserId, + groupConversationId = groupConversationId, +) + +@Serializable +data class ConnectionRequestIgnoredResult( + @SerialName("userId") + val userName: String, +) { + init { + require(userName.isNotBlank()) { "An ignored connection request user name cannot be blank" } + } +} + +internal val ConnectionRequestIgnoredResultContract = + WireNavResultContract( + WireNavResultContractId("user-profile.connection-request-ignored") + ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index a978eebf42d..1e532890b6d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.userprofile.other -import com.wire.android.navigation.annotation.app.WireRootDestination import android.annotation.SuppressLint import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility @@ -49,15 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp -import com.wire.android.ui.home.settings.otherUserProfileScreenViewModel -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R -import com.wire.android.navigation.style.PopUpNavigationAnimation -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.common.CollapsingTopBarScaffold import com.wire.android.ui.common.HandleActions @@ -81,15 +72,8 @@ import com.wire.android.ui.common.topappbar.WireTopAppBarTitle import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.common.connection.ConnectionActionButton -import com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationMediaScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DebugConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.DeviceDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SearchConversationMessagesScreenDestination import com.wire.android.ui.home.conversations.details.SearchAndMediaRow import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs -import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavBackArgs import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.legalhold.banner.LegalHoldSubjectBanner import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectProfileDialog @@ -110,34 +94,22 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.datetime.Instant -@WireRootDestination( - navArgs = OtherUserProfileNavArgs::class, - style = PopUpNavigationAnimation::class, // default should be PopUpNavigationAnimation -) +@Suppress("ComposeViewModelForwarding") @Composable -fun OtherUserProfileScreen( - navigator: Navigator, - navArgs: OtherUserProfileNavArgs, - resultNavigator: ResultBackNavigator, - conversationFoldersScreenResultRecipient: - ResultRecipient, - viewModel: OtherUserProfileScreenViewModel = otherUserProfileScreenViewModel() +internal fun OtherUserProfileRouteScreen( + viewModel: OtherUserProfileScreenViewModel, + onNavigateBack: () -> Unit, + onIgnoredConnectionRequest: (String) -> Unit, + onOpenConversation: (ConversationId) -> Unit, + onOpenDeviceDetails: (Device) -> Unit, + onSearchConversationMessages: (ConversationId) -> Unit, + onOpenConversationMedia: (ConversationId) -> Unit, + onMoveToFolder: (ConversationId, (String) -> Unit) -> Unit, + onOpenConversationDebugMenu: (ConversationId) -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current val context = LocalContext.current val scope = rememberCoroutineScope() - val onSearchConversationMessagesClick: () -> Unit = { - viewModel.state.activeOneOnOneConversationId?.let { - navigator.navigate(NavigationCommand(SearchConversationMessagesScreenDestination(conversationId = it))) - } - } - - val onConversationMediaClick: () -> Unit = { - viewModel.state.activeOneOnOneConversationId?.let { - navigator.navigate(NavigationCommand(ConversationMediaScreenDestination(conversationId = it))) - } - } - val legalHoldSubjectDialogState = rememberVisibilityState() val conversationOptionsSheetState = rememberWireModalSheetState() val changeRoleSheetState = rememberWireModalSheetState() @@ -145,43 +117,28 @@ fun OtherUserProfileScreen( OtherProfileScreenContent( scope = scope, state = viewModel.state, - conversationOptionsSheetState = conversationOptionsSheetState, + conversationOptionsSheetState = conversationOptionsSheetState, changeRoleSheetState = changeRoleSheetState, removeMemberDialogState = viewModel.removeConversationMemberDialogState, - eventsHandler = viewModel as OtherUserProfileEventsHandler, + eventsHandler = viewModel, onChangeMemberRole = viewModel::onChangeMemberRole, - onIgnoreConnectionRequest = { - resultNavigator.setResult(it) - resultNavigator.navigateBack() - }, - onOpenConversation = { - navigator.navigate( - NavigationCommand( - ConversationScreenDestination(it), - BackStackMode.UPDATE_EXISTED - ) - ) + onIgnoreConnectionRequest = onIgnoredConnectionRequest, + onOpenConversation = onOpenConversation, + onOpenDeviceDetails = onOpenDeviceDetails, + onSearchConversationMessagesClick = { + viewModel.state.activeOneOnOneConversationId?.let(onSearchConversationMessages) }, - onOpenDeviceDetails = { - navigator.navigate( - NavigationCommand( - DeviceDetailsScreenDestination( - navArgs.userId, - it.clientId - ) - ) - ) + navigateBack = onNavigateBack, + onConversationMediaClick = { + viewModel.state.activeOneOnOneConversationId?.let(onOpenConversationMedia) }, - onSearchConversationMessagesClick = onSearchConversationMessagesClick, - navigateBack = navigator::navigateBack, - onConversationMediaClick = onConversationMediaClick, onLegalHoldLearnMoreClick = remember { { legalHoldSubjectDialogState.show(Unit) } }, - onMoveToFolder = { - navigator.navigate(NavigationCommand(ConversationFoldersScreenDestination(it))) - }, - openConversationDebugMenu = { conversationId -> - navigator.navigate(NavigationCommand(DebugConversationScreenDestination(conversationId))) + onMoveToFolder = { arguments -> + onMoveToFolder(arguments.conversationId) { message -> + scope.launch { snackbarHostState.showSnackbar(message) } + } }, + openConversationDebugMenu = onOpenConversationDebugMenu, ) HandleActions(viewModel.actions) { action -> @@ -203,18 +160,7 @@ fun OtherUserProfileScreen( } if (viewModel.state.errorLoadingUser != null) { - UserNotFoundDialog(onActionButtonClicked = navigator::navigateBack) - } - - conversationFoldersScreenResultRecipient.onNavResult { result -> - when (result) { - NavResult.Canceled -> {} - is NavResult.Value -> { - scope.launch { - snackbarHostState.showSnackbar(result.value.message) - } - } - } + UserNotFoundDialog(onActionButtonClicked = onNavigateBack) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index fd3052a43a3..270cf67665f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.userprofile.other import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.appLogger import com.wire.android.mapper.UserTypeMapper @@ -30,7 +29,6 @@ import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveConversationRoleForUserUseCase import com.wire.android.ui.home.conversationslist.model.BlockState -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.mapper.UsernameMapper.fromOtherUser import com.wire.android.ui.userprofile.group.RemoveConversationMemberState import com.wire.android.ui.userprofile.other.OtherUserProfileInfoMessageType.ChangeGroupRoleError @@ -77,15 +75,19 @@ class OtherUserProfileScreenViewModel @AssistedInject constructor( private val isOneToOneConversationCreated: IsOneToOneConversationCreatedUseCase, private val mlsClientIdentity: GetMLSClientIdentityUseCase, private val isE2EIEnabled: IsE2EIEnabledUseCase, - @Assisted savedStateHandle: SavedStateHandle + @Assisted navigationArgs: OtherUserProfileViewModelArgs, ) : ActionsViewModel(), OtherUserProfileEventsHandler { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): OtherUserProfileScreenViewModel + fun create(navigationArgs: OtherUserProfileViewModelArgs): OtherUserProfileScreenViewModel + } + private val userId: QualifiedID = QualifiedID( + navigationArgs.targetUserId.value, + navigationArgs.targetUserId.domain, + ) + private val groupConversationId: QualifiedID? = navigationArgs.groupConversationId?.let { + QualifiedID(it.value, it.domain) } - private val otherUserProfileNavArgs: OtherUserProfileNavArgs = savedStateHandle.navArgs() - private val userId: QualifiedID = otherUserProfileNavArgs.userId - private val groupConversationId: QualifiedID? = otherUserProfileNavArgs.groupConversationId var state: OtherUserProfileState by mutableStateOf( OtherUserProfileState( userId = userId, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt index 2d0ed19d208..3f83b348292 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.userprofile.qr -import com.wire.android.navigation.annotation.app.WireRootDestination import android.annotation.SuppressLint import android.graphics.Bitmap import android.net.Uri @@ -57,13 +56,10 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import com.wire.android.ui.home.settings.selfQRCodeViewModel import com.lightspark.composeqr.DotShape import com.lightspark.composeqr.QrCodeView import com.wire.android.R import com.wire.android.feature.analytics.model.AnalyticsEvent -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -77,23 +73,19 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.launch -@WireRootDestination( - style = SlideNavigationAnimation::class, - navArgs = SelfQrCodeNavArgs::class -) @Composable -fun SelfQRCodeScreen( - navigator: Navigator, - viewModel: SelfQRCodeViewModel = selfQRCodeViewModel() +internal fun SelfQRCodeScreen( + viewModel: SelfQRCodeViewModel, + onNavigateBack: () -> Unit, ) { if (viewModel.selfQRCodeState.hasError) { - navigator.navigateBack() + onNavigateBack() } SelfQRCodeContent( viewModel.selfQRCodeState, viewModel::shareQRAsset, viewModel::trackAnalyticsEvent, - navigator::navigateBack + onNavigateBack ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModel.kt index 1e27ebcb285..d268451c2e8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModel.kt @@ -22,7 +22,6 @@ import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger @@ -30,7 +29,6 @@ import com.wire.android.di.ApplicationContext import com.wire.android.di.CurrentAccount import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.getTempWritableAttachmentUri import com.wire.kalium.logic.data.asset.KaliumFileSystem @@ -46,7 +44,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class SelfQRCodeViewModel @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted private val navigationArgs: SelfQrCodeViewModelArgs, @ApplicationContext private val context: Context, @CurrentAccount private val selfUserId: UserId, private val selfServerLinks: SelfServerConfigUseCase, @@ -56,14 +54,13 @@ class SelfQRCodeViewModel @AssistedInject constructor( ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): SelfQRCodeViewModel + fun create(navigationArgs: SelfQrCodeViewModelArgs): SelfQRCodeViewModel } - private val selfQrCodeNavArgs: SelfQrCodeNavArgs = savedStateHandle.navArgs() var selfQRCodeState by mutableStateOf( SelfQRCodeState( selfUserId, - handle = selfQrCodeNavArgs.userHandle, - isTeamMember = selfQrCodeNavArgs.isTeamMember + handle = navigationArgs.userHandle, + isTeamMember = navigationArgs.isTeamMember ) ) private set diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeLegacyNavigationMappers.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeLegacyNavigationMappers.kt new file mode 100644 index 00000000000..bb84324c9bb --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeLegacyNavigationMappers.kt @@ -0,0 +1,34 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.qr + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId + +internal fun SelfQrCodeNavArgs.toSelfQrCodeRoute( + sessionId: WireSessionId, + entryId: WireNavEntryId = WireNavEntryId.random(), +) = SelfQrCodeRoute( + sessionId = sessionId, + userHandle = userHandle, + isTeamMember = isTeamMember, + entryId = entryId, +) + +internal fun SelfQrCodeRoute.toLegacyNavArgs() = SelfQrCodeNavArgs( + userHandle = userHandle, + isTeamMember = isTeamMember, +) + +internal fun SelfQrCodeNavArgs.toViewModelArgs() = SelfQrCodeViewModelArgs( + userHandle = userHandle, + isTeamMember = isTeamMember, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3.kt new file mode 100644 index 00000000000..f1b5b298fb0 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3.kt @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.qr + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class SelfQrCodeViewModelArgs( + val userHandle: String = "", + val isTeamMember: Boolean, +) + +@Serializable +data class SelfQrCodeRoute( + override val sessionId: WireSessionId, + val userHandle: String = "", + val isTeamMember: Boolean, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + // Compose Destinations preserves the acronym as q_r; keep it stable for analytics. + const val ROUTE_ID = "app/self_q_r_code_screen" + } +} + +internal fun SelfQrCodeRoute.toViewModelArgs() = SelfQrCodeViewModelArgs( + userHandle = userHandle, + isTeamMember = isTeamMember, +) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileRoute.kt similarity index 55% rename from features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt rename to app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileRoute.kt index f30b4a4ede6..7948dfa8d7e 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileRoute.kt @@ -15,16 +15,23 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.meetings.navigation -import com.wire.android.navigation.WireNavigator -import com.wire.kalium.logic.data.user.UserId +package com.wire.android.ui.userprofile.self -/** - * Navigator for the meetings feature. It extends [WireNavigator] and adds meeting-specific navigation functions that allows navigating to - * different screens from other modules without needing to know about the implementation details of the whole navigation system. - */ -class MeetingNavigator( - val navigator: WireNavigator, - val navigateToProfile: (userId: UserId) -> Unit -) : WireNavigator by navigator +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class SelfUserProfileRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/self_user_profile_screen" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt index 72585027aae..5a4ab61486c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.userprofile.self -import com.wire.android.navigation.annotation.app.WireRootDestination import android.annotation.SuppressLint import android.content.Context import android.widget.Toast @@ -47,22 +46,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.wire.android.ui.home.settings.selfUserProfileViewModel -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.R as UICommonR -import com.wire.android.appLogger -import com.wire.android.feature.NavigationSwitchAccountActions import com.wire.android.model.ClickBlockParams import com.wire.android.model.Clickable -import com.wire.android.navigation.LoginTypeSelector -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationTeamPlanStepScreenDestination import com.wire.android.ui.common.VisibilityState -import com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanNavArgs import com.wire.android.ui.common.WireDropDown import com.wire.android.ui.common.avatar.UserStatusIndicator import com.wire.android.ui.common.button.WireButtonState @@ -77,12 +65,6 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.common.wireDialogPropertiesBuilder -import com.ramcosta.composedestinations.generated.app.destinations.AppSettingsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.AvatarPickerScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.MyAccountScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfQRCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.legalhold.banner.LegalHoldPendingBanner import com.wire.android.ui.legalhold.banner.LegalHoldSubjectBanner @@ -91,7 +73,6 @@ import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedDialog import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedState import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectProfileSelfDialog -import com.wire.android.ui.legalHoldRequestedViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -112,95 +93,58 @@ val LocalSelfUserProfileLogoutAction = staticCompositionLocalOf<((wipeData: Bool null } -@WireRootDestination( - style = PopUpNavigationAnimation::class, // default should be PopUpNavigationAnimation -) @Composable @SuppressLint("ComposeModifierMissing") -fun SelfUserProfileScreen( - navigator: Navigator, - loginTypeSelector: LoginTypeSelector, - avatarPickerResultRecipient: ResultRecipient, - viewModelSelf: SelfUserProfileViewModel = selfUserProfileViewModel(), - legalHoldRequestedViewModel: LegalHoldRequestedViewModel = legalHoldRequestedViewModel() +@Suppress("LongParameterList") +internal fun SelfUserProfileRouteScreen( + viewModelSelf: SelfUserProfileViewModel, + legalHoldRequestedViewModel: LegalHoldRequestedViewModel, + onClose: () -> Unit, + onLogout: (Boolean) -> Unit, + onChangeAvatar: () -> Unit, + onEditProfile: () -> Unit, + onAddAccount: () -> Unit, + onSwitchAccount: (UserId) -> Unit, + onOpenQrCode: (userHandle: String, isTeamMember: Boolean) -> Unit, + onCreateTeam: (wasMigrationDotActive: Boolean) -> Unit, + onOpenAccountDetails: () -> Unit, ) { val legalHoldSubjectDialogState = rememberVisibilityState() val logoutAction = LocalSelfUserProfileLogoutAction.current LaunchedEffect(Unit) { - // Check if the user is able to migrate to a team account, every time the screen is shown viewModelSelf.checkIfUserAbleToMigrateToTeamAccount() } SelfUserProfileContent( state = viewModelSelf.userProfileState, - onCloseClick = navigator::navigateBack, - logout = { - logoutAction?.invoke(it) - ?: viewModelSelf.logout(it, NavigationSwitchAccountActions(navigator::navigate, loginTypeSelector::canUseNewLogin)) - }, - onChangeUserProfilePicture = { - navigator.navigate( - NavigationCommand( - AvatarPickerScreenDestination - ) - ) - }, - onEditClick = { navigator.navigate(NavigationCommand(AppSettingsScreenDestination)) }, + onCloseClick = onClose, + logout = { wipeData -> logoutAction?.invoke(wipeData) ?: onLogout(wipeData) }, + onChangeUserProfilePicture = onChangeAvatar, + onEditClick = onEditProfile, onStatusClicked = viewModelSelf::changeStatusClick, - onAddAccountClick = { - val destination = if (loginTypeSelector.canUseNewLogin()) NewLoginScreenDestination() else WelcomeScreenDestination() - navigator.navigate(NavigationCommand(destination)) - }, + onAddAccountClick = onAddAccount, dismissStatusDialog = viewModelSelf::dismissStatusDialog, onStatusChange = viewModelSelf::changeStatus, onNotShowRationaleAgainChange = viewModelSelf::dialogCheckBoxStateChanged, onMessageShown = viewModelSelf::clearErrorMessage, onLegalHoldAcceptClick = legalHoldRequestedViewModel::show, onLegalHoldLearnMoreClick = remember { { legalHoldSubjectDialogState.show(Unit) } }, - onOtherAccountClick = { - viewModelSelf.switchAccount(it, NavigationSwitchAccountActions(navigator::navigate, loginTypeSelector::canUseNewLogin)) - }, + onOtherAccountClick = onSwitchAccount, onQrCodeClick = { viewModelSelf.trackQrCodeClick() - navigator.navigate( - NavigationCommand( - SelfQRCodeScreenDestination( - viewModelSelf.userProfileState.userName, - !viewModelSelf.userProfileState.teamName.isNullOrBlank() - ) - ) + onOpenQrCode( + viewModelSelf.userProfileState.userName, + !viewModelSelf.userProfileState.teamName.isNullOrBlank(), ) }, onCreateAccount = { - navigator.navigate( - NavigationCommand( - TeamMigrationTeamPlanStepScreenDestination( - TeamMigrationTeamPlanNavArgs(viewModelSelf.wasMigrationDotActiveWhenProfileOpened()) - ) - ) - ) + onCreateTeam(viewModelSelf.wasMigrationDotActiveWhenProfileOpened()) }, - onAccountDetailsClick = { navigator.navigate(NavigationCommand(MyAccountScreenDestination)) }, + onAccountDetailsClick = onOpenAccountDetails, isUserInCall = viewModelSelf::isUserInCall, ) - avatarPickerResultRecipient.onNavResult { result -> - when (result) { - is NavResult.Canceled -> { - appLogger.i("Error with receiving navigation back args from avatar picker in SelfUserProfileScreen") - } - - is NavResult.Value -> { - result.value?.let { avatarAssetId -> - viewModelSelf.reloadNewPickedAvatar( - avatarAssetId = avatarAssetId - ) - } - } - } - } - if (legalHoldRequestedViewModel.state is LegalHoldRequestedState.Visible) { LegalHoldRequestedDialog( state = legalHoldRequestedViewModel.state as LegalHoldRequestedState.Visible, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsLegacyNavigationMappers.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsLegacyNavigationMappers.kt new file mode 100644 index 00000000000..52f47c3d828 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsLegacyNavigationMappers.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.service + +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.android.ui.userprofile.toQualifiedId +import com.wire.android.ui.userprofile.toUserProfileQualifiedId +import com.wire.kalium.logic.data.user.BotService +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId + +internal fun ServiceDetailsNavArgs.toServiceDetailsRoute( + sessionId: WireSessionId, + entryId: WireNavEntryId = WireNavEntryId.random(), +) = ServiceDetailsRoute( + sessionId = sessionId, + conversationId = conversationId?.toUserProfileQualifiedId(), + target = id.toServiceProfileTarget(), + entryId = entryId, +) + +internal fun ServiceDetailsRoute.toLegacyNavArgs() = ServiceDetailsNavArgs( + conversationId = conversationId?.toQualifiedId(), + id = target.toLegacyId(), +) + +internal fun ServiceDetailsNavArgs.toViewModelArgs() = ServiceDetailsViewModelArgs( + conversationId = conversationId?.toUserProfileQualifiedId(), + target = id.toServiceProfileTarget(), +) + +private fun ServiceDetailsNavArgs.Id.toServiceProfileTarget(): ServiceProfileTarget = when (this) { + is ServiceDetailsNavArgs.Id.AppId -> ServiceProfileTarget.App( + appId.toUserProfileQualifiedId() + ) + + is ServiceDetailsNavArgs.Id.BotServiceId -> ServiceProfileTarget.Bot( + UserProfileQualifiedId( + value = botService.id, + domain = botService.provider, + ) + ) +} + +private fun ServiceProfileTarget.toLegacyId(): ServiceDetailsNavArgs.Id = when (this) { + is ServiceProfileTarget.App -> ServiceDetailsNavArgs.Id.AppId(id.toQualifiedId()) + is ServiceProfileTarget.Bot -> ServiceDetailsNavArgs.Id.BotServiceId( + BotService( + id = id.value, + provider = id.domain, + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt index 49fb12aa70d..87bd724e69c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavArgs.kt @@ -17,11 +17,7 @@ */ package com.wire.android.ui.userprofile.service -import com.ramcosta.composedestinations.navargs.DestinationsNavTypeSerializer -import com.ramcosta.composedestinations.navargs.NavTypeSerializer -import com.wire.android.util.EMPTY import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.id.VALUE_DOMAIN_SEPARATOR import com.wire.kalium.logic.data.service.ServiceId import com.wire.kalium.logic.data.user.BotService import com.wire.kalium.logic.data.user.UserId @@ -53,38 +49,3 @@ data class ServiceDetailsNavArgs( } } } - -@NavTypeSerializer -class ServiceDetailsIdNavTypeSerializer : DestinationsNavTypeSerializer { - override fun toRouteString(value: ServiceDetailsNavArgs.Id): String = when (value) { - is ServiceDetailsNavArgs.Id.AppId -> APP_PREFIX + value.appId.toString() - is ServiceDetailsNavArgs.Id.BotServiceId -> BOT_PREFIX + value.botService.toString() - } - - override fun fromRouteString(routeStr: String): ServiceDetailsNavArgs.Id = when { - routeStr.startsWith(APP_PREFIX) -> { - routeStr.removePrefix(APP_PREFIX).split(VALUE_DOMAIN_SEPARATOR).takeIf { - it.size > 1 - }?.let { - ServiceDetailsNavArgs.Id.AppId(UserId(it.first(), it.last())) - } ?: run { - ServiceDetailsNavArgs.Id.AppId(UserId(routeStr.removePrefix(APP_PREFIX), String.EMPTY)) - } - } - routeStr.startsWith(BOT_PREFIX) -> { - routeStr.removePrefix(BOT_PREFIX).split(VALUE_DOMAIN_SEPARATOR).takeIf { - it.size > 1 - }?.let { - ServiceDetailsNavArgs.Id.BotServiceId(BotService(it.first(), it.last())) - } ?: run { - ServiceDetailsNavArgs.Id.BotServiceId(BotService(routeStr.removePrefix(BOT_PREFIX), String.EMPTY)) - } - } - else -> throw IllegalArgumentException("Invalid route string for ServiceDetailsNavArgs.Id: $routeStr") - } - - private companion object { - const val BOT_PREFIX = "bot:" - const val APP_PREFIX = "app:" - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3.kt new file mode 100644 index 00000000000..8f91fd5da7d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.userprofile.service + +import com.wire.android.ui.userprofile.UserProfileQualifiedId +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +sealed interface ServiceProfileTarget { + val id: UserProfileQualifiedId + + @Serializable + data class Bot( + override val id: UserProfileQualifiedId, + ) : ServiceProfileTarget + + @Serializable + data class App( + override val id: UserProfileQualifiedId, + ) : ServiceProfileTarget +} + +@Serializable +data class ServiceDetailsViewModelArgs( + val conversationId: UserProfileQualifiedId?, + val target: ServiceProfileTarget, +) + +@Serializable +data class ServiceDetailsRoute( + override val sessionId: WireSessionId, + val conversationId: UserProfileQualifiedId?, + val target: ServiceProfileTarget, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "app/service_details_screen" + } +} + +internal fun ServiceDetailsRoute.toViewModelArgs() = ServiceDetailsViewModelArgs( + conversationId = conversationId, + target = target, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt index 6e48739701e..717f0cfd0c7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.userprofile.service -import com.wire.android.navigation.annotation.app.WireRootDestination import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -41,20 +40,14 @@ import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination import com.wire.android.R import com.wire.android.ui.common.R as commonR -import com.wire.android.ui.home.settings.serviceDetailsViewModel import com.wire.android.model.ClickBlockParams import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType import com.wire.android.model.Clickable -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.UserBadge import com.wire.android.ui.common.button.WireButtonState @@ -64,23 +57,19 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar -import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.service.ServiceDetails import kotlinx.coroutines.launch -@WireRootDestination( - navArgs = ServiceDetailsNavArgs::class, - style = PopUpNavigationAnimation::class, // default should be PopUpNavigationAnimation -) @Composable -fun ServiceDetailsScreen( - navigator: Navigator, - viewModel: ServiceDetailsViewModel = - serviceDetailsViewModel() +internal fun ServiceDetailsScreen( + viewModel: ServiceDetailsViewModel, + navigateBack: () -> Unit, + openConversation: (ConversationId) -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current val coroutineScope = rememberCoroutineScope() @@ -95,21 +84,12 @@ fun ServiceDetailsScreen( } is ServiceDetailsViewActions.OpenConversation -> - navigator.navigate( - NavigationCommand( - ConversationScreenDestination( - navArgs = ConversationNavArgs( - conversationId = action.conversationId - ) - ), - BackStackMode.UPDATE_EXISTED - ) - ) + openConversation(action.conversationId) } } ServiceDetailsContent( - navigateBack = navigator::navigateBack, + navigateBack = navigateBack, onAddService = viewModel::onAddService, onRemoveService = viewModel::onRemoveService, onOpenConversation = viewModel::onOpenConversation, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt index bc3279e538c..a3574191b94 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModel.kt @@ -19,12 +19,10 @@ package com.wire.android.ui.userprofile.service import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.di.CurrentAccount import com.wire.android.model.ImageAsset import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveConversationRoleForUserUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.appLogger import com.wire.android.di.ViewModelScopedPreview import com.wire.android.model.asSnackBarMessage @@ -87,16 +85,23 @@ class ServiceDetailsViewModelImpl @AssistedInject constructor( private val addMemberToConversation: AddMemberToConversationUseCase, private val isOneToOneConversationCreated: IsOneToOneConversationCreatedUseCase, private val getOrCreateOneToOneConversation: GetOrCreateOneToOneConversationUseCase, - @Assisted savedStateHandle: SavedStateHandle + @Assisted private val navigationArgs: ServiceDetailsViewModelArgs, ) : ServiceDetailsViewModel, ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): ServiceDetailsViewModelImpl + fun create(navigationArgs: ServiceDetailsViewModelArgs): ServiceDetailsViewModelImpl + } + private val serviceId: ServiceId = ServiceId( + navigationArgs.target.id.value, + navigationArgs.target.id.domain, + ) + private val userId: UserId = UserId( + navigationArgs.target.id.value, + navigationArgs.target.id.domain, + ) + private val conversationId: QualifiedID? = navigationArgs.conversationId?.let { + QualifiedID(it.value, it.domain) } - private val serviceDetailsNavArgs: ServiceDetailsNavArgs = savedStateHandle.navArgs() - private val serviceId: ServiceId = serviceDetailsNavArgs.id.serviceId - private val userId: UserId = serviceDetailsNavArgs.id.userId - private val conversationId: QualifiedID? = serviceDetailsNavArgs.conversationId private var state by mutableStateOf(ServiceDetailsState()) override fun serviceDetailsState(): ServiceDetailsState = state init { @@ -120,14 +125,15 @@ class ServiceDetailsViewModelImpl @AssistedInject constructor( isAvatarLoading = true, isAppsEnabled = isAppsEnabled ) - when (val id = serviceDetailsNavArgs.id) { - is ServiceDetailsNavArgs.Id.AppId -> { - val details = getAppDetailsAndUpdateViewState(id.appId) + when (navigationArgs.target) { + is ServiceProfileTarget.App -> { + val details = getAppDetailsAndUpdateViewState(userId) if (details != null && isAppsEnabled) { - observeIsAppConversationMember(id.appId) + observeIsAppConversationMember(userId) } } - is ServiceDetailsNavArgs.Id.BotServiceId -> { + + is ServiceProfileTarget.Bot -> { getServiceDetailsAndUpdateViewState()?.let { observeIsServiceConversationMember() } @@ -138,22 +144,22 @@ class ServiceDetailsViewModelImpl @AssistedInject constructor( override fun onAddService() { viewModelScope.launch { state = state.copy(isActionLoading = true) - val responseMessage = when (val id = serviceDetailsNavArgs.id) { - is ServiceDetailsNavArgs.Id.AppId -> { + val responseMessage = when (navigationArgs.target) { + is ServiceProfileTarget.App -> { val response = addMemberToConversation.invoke( conversationId = requireNotNull(conversationId), - userIdList = listOf(id.appId) + userIdList = listOf(userId) ) when (response) { is AddMemberToConversationUseCase.Result.Failure -> ServiceDetailsInfoMessageType.ErrorAddService is AddMemberToConversationUseCase.Result.Success -> ServiceDetailsInfoMessageType.SuccessAddService } } - is ServiceDetailsNavArgs.Id.BotServiceId -> { + is ServiceProfileTarget.Bot -> { val response = withContext(dispatchers.io()) { addServiceToConversation.invoke( conversationId = requireNotNull(conversationId), - serviceId = id.serviceId + serviceId = serviceId ) } when (response) { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/PersonalToTeamMigrationNavGraph.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/PersonalToTeamMigrationNavGraph.kt deleted file mode 100644 index 1ed7f849579..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/PersonalToTeamMigrationNavGraph.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.userprofile.teammigration - -import com.ramcosta.composedestinations.annotation.NavGraph -import com.wire.android.navigation.WireRootNavGraph - -@NavGraph -annotation class PersonalToTeamMigrationNavGraph( - val start: Boolean = false -) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3.kt new file mode 100644 index 00000000000..a4cca1b91fb --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3.kt @@ -0,0 +1,114 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.teammigration + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +/** + * One personal-to-team migration owns one [TeamMigrationViewModel] across all four steps. + * + * The explicit flow id is serialized with every entry, so Navigation 3 can restore the shared + * ViewModel owner without deriving ownership from a mutable back-stack position. + */ +sealed interface TeamMigrationRoute : SessionRoute { + override val flowId: String +} + +@Serializable +data class TeamMigrationTeamPlanRoute( + override val sessionId: WireSessionId, + override val flowId: String, + val isMigrationDotActive: Boolean = false, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : TeamMigrationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateTeamMigrationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/team_migration_team_plan_step_screen" + + fun start( + sessionId: WireSessionId, + isMigrationDotActive: Boolean = false, + entryId: WireNavEntryId = WireNavEntryId.random(), + ) = TeamMigrationTeamPlanRoute( + sessionId = sessionId, + flowId = "team-migration:${entryId.value}", + isMigrationDotActive = isMigrationDotActive, + entryId = entryId, + ) + } +} + +@Serializable +data class TeamMigrationTeamNameRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : TeamMigrationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateTeamMigrationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/team_migration_team_name_step_screen" + } +} + +@Serializable +data class TeamMigrationConfirmationRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : TeamMigrationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateTeamMigrationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/team_migration_confirmation_step_screen" + } +} + +@Serializable +data class TeamMigrationDoneRoute( + override val sessionId: WireSessionId, + override val flowId: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : TeamMigrationRoute { + override val routeId: String + get() = ROUTE_ID + + init { + validateTeamMigrationFlowId(flowId) + } + + companion object { + const val ROUTE_ID = "app/team_migration_done_step_screen" + } +} + +private fun validateTeamMigrationFlowId(flowId: String) { + require(flowId.isNotBlank()) { "A team-migration flow id cannot be blank" } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Entries.kt new file mode 100644 index 00000000000..4137679f95d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Entries.kt @@ -0,0 +1,149 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.teammigration + +import androidx.compose.runtime.Composable +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.ui.home.settings.teamMigrationViewModel +import com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanRouteScreen +import com.wire.android.ui.userprofile.teammigration.step2.TeamMigrationTeamNameRouteScreen +import com.wire.android.ui.userprofile.teammigration.step3.TeamMigrationConfirmationRouteScreen +import com.wire.android.ui.userprofile.teammigration.step4.TeamMigrationDoneRouteScreen +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireViewModelOwner + +/** + * Host-owned exits whose destinations live outside the migration flow. + */ +internal interface TeamMigrationNavigation3Actions { + fun exitMigration() + fun completeMigrationToHome() +} + +internal object TeamMigrationNavigation3Contribution { + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: TeamMigrationNavigation3Actions, + ): List = listOf( + teamMigrationNavigation3Entries(runtime, actions) + ) +} + +internal fun teamMigrationNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: TeamMigrationNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + TeamMigrationTeamPlanNavigation3Entry(route, runtime, actions) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + TeamMigrationTeamNameNavigation3Entry(route, runtime) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + TeamMigrationConfirmationNavigation3Entry(route, runtime) + } + wireEntry(presentation = WireEntryPresentation.Slide) { route -> + TeamMigrationDoneNavigation3Entry(route, actions) + } +} + +@Composable +private fun TeamMigrationTeamPlanNavigation3Entry( + route: TeamMigrationTeamPlanRoute, + runtime: WireNavigation3Runtime, + actions: TeamMigrationNavigation3Actions, +) { + TeamMigrationTeamPlanRouteScreen( + teamMigrationViewModel = teamMigrationFlowViewModel(route.flowId), + isMigrationDotActive = route.isMigrationDotActive, + onBackButtonClicked = { + if (!runtime.navigator.goBack()) actions.exitMigration() + }, + onContinueButtonClicked = { + runtime.navigator.navigate( + WireNavigationCommand( + TeamMigrationTeamNameRoute(route.sessionId, route.flowId) + ) + ) + }, + ) +} + +@Composable +private fun TeamMigrationTeamNameNavigation3Entry( + route: TeamMigrationTeamNameRoute, + runtime: WireNavigation3Runtime, +) { + TeamMigrationTeamNameRouteScreen( + teamMigrationViewModel = teamMigrationFlowViewModel(route.flowId), + onBackButtonClicked = runtime.navigator::goBack, + onContinueButtonClicked = { + runtime.navigator.navigate( + WireNavigationCommand( + TeamMigrationConfirmationRoute(route.sessionId, route.flowId) + ) + ) + }, + ) +} + +@Composable +private fun TeamMigrationConfirmationNavigation3Entry( + route: TeamMigrationConfirmationRoute, + runtime: WireNavigation3Runtime, +) { + TeamMigrationConfirmationRouteScreen( + teamMigrationViewModel = teamMigrationFlowViewModel(route.flowId), + onBackButtonClicked = runtime.navigator::goBack, + onMigrationCompleted = { + runtime.navigator.navigate( + WireNavigationCommand( + destination = TeamMigrationDoneRoute(route.sessionId, route.flowId), + backStackMode = WireBackStackMode.REMOVE_CURRENT_NESTED_FLOW, + ) + ) + }, + onReturnToFirstStep = { isMigrationDotActive -> + runtime.navigator.navigate( + WireNavigationCommand( + destination = TeamMigrationTeamPlanRoute( + sessionId = route.sessionId, + flowId = route.flowId, + isMigrationDotActive = isMigrationDotActive, + ), + backStackMode = WireBackStackMode.UPDATE_EXISTING, + ) + ) + }, + ) +} + +@Composable +private fun TeamMigrationDoneNavigation3Entry( + route: TeamMigrationDoneRoute, + actions: TeamMigrationNavigation3Actions, +) { + TeamMigrationDoneRouteScreen( + teamMigrationViewModel = teamMigrationFlowViewModel(route.flowId), + onBackToWireClicked = actions::completeMigrationToHome, + ) +} + +@Composable +private fun teamMigrationFlowViewModel(flowId: String): TeamMigrationViewModel { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(flowId)) + return teamMigrationViewModel(viewModelStoreOwner = flowOwner) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt index 843772c6d7c..f295900d847 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step1/TeamMigrationTeamPlanStepScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.userprofile.teammigration.step1 -import com.wire.android.navigation.annotation.app.WirePersonalToTeamMigrationDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -45,12 +44,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthPopUpNavigationAnimation import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions -import com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationTeamNameStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel @@ -59,28 +54,22 @@ import com.wire.android.ui.userprofile.teammigration.common.TeamMigrationContain import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes -@WirePersonalToTeamMigrationDestination( - start = true, - navArgs = TeamMigrationTeamPlanNavArgs::class, - style = AuthPopUpNavigationAnimation::class -) @Composable -fun TeamMigrationTeamPlanStepScreen( - navigator: Navigator, - navArgs: TeamMigrationTeamPlanNavArgs, - teamMigrationViewModel: TeamMigrationViewModel +internal fun TeamMigrationTeamPlanRouteScreen( + teamMigrationViewModel: TeamMigrationViewModel, + isMigrationDotActive: Boolean, + onBackButtonClicked: () -> Unit, + onContinueButtonClicked: () -> Unit, ) { TeamMigrationTeamPlanStepScreenContent( - onBackButtonClicked = navigator::navigateBack, - onContinueButtonClicked = { - navigator.navigate(NavigationCommand(TeamMigrationTeamNameStepScreenDestination)) - } + onBackButtonClicked = onBackButtonClicked, + onContinueButtonClicked = onContinueButtonClicked, ) LaunchedEffect(Unit) { teamMigrationViewModel.setCurrentStep( step = TeamMigrationViewModel.TEAM_MIGRATION_TEAM_PLAN_STEP, - isMigrationDotActive = navArgs.isMigrationDotActive + isMigrationDotActive = isMigrationDotActive, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt index a43668da7ef..a4b7cee37cf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.userprofile.teammigration.step2 -import com.wire.android.navigation.annotation.app.WirePersonalToTeamMigrationDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -35,13 +34,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import com.wire.android.R -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthSlideNavigationAnimation import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.textfield.WireTextField -import com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationConfirmationStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel @@ -49,20 +44,16 @@ import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.ui.userprofile.teammigration.common.TeamMigrationContainer import com.wire.android.util.ui.PreviewMultipleThemes -@WirePersonalToTeamMigrationDestination( - style = AuthSlideNavigationAnimation::class -) @Composable -fun TeamMigrationTeamNameStepScreen( - navigator: Navigator, - teamMigrationViewModel: TeamMigrationViewModel +internal fun TeamMigrationTeamNameRouteScreen( + teamMigrationViewModel: TeamMigrationViewModel, + onContinueButtonClicked: () -> Unit, + onBackButtonClicked: () -> Unit, ) { TeamMigrationTeamNameStepScreenContent( - onContinueButtonClicked = { - navigator.navigate(NavigationCommand(TeamMigrationConfirmationStepScreenDestination)) - }, - onBackButtonClicked = navigator::navigateBack, - teamNameTextFieldState = teamMigrationViewModel.teamMigrationState.teamNameTextState + onContinueButtonClicked = onContinueButtonClicked, + onBackButtonClicked = onBackButtonClicked, + teamNameTextFieldState = teamMigrationViewModel.teamMigrationState.teamNameTextState, ) LaunchedEffect(Unit) { teamMigrationViewModel.setCurrentStep(TeamMigrationViewModel.TEAM_MIGRATION_TEAM_NAME_STEP) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt index 686fb47c0fa..06e8884dd74 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.userprofile.teammigration.step3 -import com.wire.android.navigation.annotation.app.WirePersonalToTeamMigrationDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -41,23 +40,16 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withLink import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthSlideNavigationAnimation import com.wire.android.ui.common.WireCheckbox import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions -import com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationDoneStepScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationTeamPlanStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.teammigration.TeamMigrationState import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel -import com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanNavArgs import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.ui.userprofile.teammigration.common.BulletList import com.wire.android.ui.userprofile.teammigration.common.TeamMigrationContainer @@ -65,41 +57,31 @@ import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamFailure -@WirePersonalToTeamMigrationDestination( - style = AuthSlideNavigationAnimation::class -) @Composable -fun TeamMigrationConfirmationStepScreen( - navigator: Navigator, - teamMigrationViewModel: TeamMigrationViewModel +internal fun TeamMigrationConfirmationRouteScreen( + teamMigrationViewModel: TeamMigrationViewModel, + onMigrationCompleted: () -> Unit, + onBackButtonClicked: () -> Unit, + onReturnToFirstStep: (isMigrationDotActive: Boolean) -> Unit, ) { val state = teamMigrationViewModel.teamMigrationState LaunchedEffect(state.migrationCompleted) { if (state.migrationCompleted) { - navigator.navigate(NavigationCommand(TeamMigrationDoneStepScreenDestination, BackStackMode.REMOVE_CURRENT_NESTED_GRAPH)) + onMigrationCompleted() } } TeamMigrationConfirmationStepScreenContent( isMigrating = state.isMigrating, onContinueButtonClicked = teamMigrationViewModel::migrateFromPersonalToTeamAccount, - onBackButtonClicked = navigator::navigateBack, + onBackButtonClicked = onBackButtonClicked, ) HandleErrors( teamMigrationState = state, onFailureHandled = teamMigrationViewModel::failureHandled, - goBackToFirstStep = { - navigator.navigate( - NavigationCommand( - TeamMigrationTeamPlanStepScreenDestination( - TeamMigrationTeamPlanNavArgs(state.isMigrationDotActive) - ), - BackStackMode.UPDATE_EXISTED - ) - ) - } + goBackToFirstStep = { onReturnToFirstStep(state.isMigrationDotActive) }, ) LaunchedEffect(Unit) { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt index 7a367a2c4f6..1a5f85e2dba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step4/TeamMigrationDoneStepScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.userprofile.teammigration.step4 -import com.wire.android.navigation.annotation.app.WirePersonalToTeamMigrationDestination import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,14 +31,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import com.wire.android.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.Navigator -import com.wire.android.navigation.style.AuthSlideNavigationAnimation import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.spacers.VerticalSpace.x32 -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel @@ -49,20 +43,15 @@ import com.wire.android.ui.userprofile.teammigration.common.TeamMigrationContain import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes -@WirePersonalToTeamMigrationDestination( - style = AuthSlideNavigationAnimation::class -) @Composable -fun TeamMigrationDoneStepScreen( - navigator: Navigator, - teamMigrationViewModel: TeamMigrationViewModel +internal fun TeamMigrationDoneRouteScreen( + teamMigrationViewModel: TeamMigrationViewModel, + onBackToWireClicked: () -> Unit, ) { val context = LocalContext.current TeamMigrationDoneStepContent( - onBackToWireClicked = { - navigator.navigate(NavigationCommand(HomeScreenDestination, BackStackMode.CLEAR_WHOLE)) - }, + onBackToWireClicked = onBackToWireClicked, onOpenTeamManagementClicked = { val teamManagementUrl = teamMigrationViewModel.teamMigrationState.teamUrl CustomTabsHelper.launchUrl(context, teamManagementUrl) diff --git a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt index 5546569b169..98611da0e41 100644 --- a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt @@ -20,45 +20,23 @@ package com.wire.android.util -import android.annotation.SuppressLint -import android.os.Bundle import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import com.ramcosta.composedestinations.spec.DestinationSpec import com.wire.android.appLogger +import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl -import com.wire.android.navigation.toDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDataDetailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountEmailScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSelectorScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSummaryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountUsernameScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateAccountVerificationCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreatePersonalAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.CreateTeamAccountOverviewScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.E2EiCertificateDetailsScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginVerificationCodeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewWelcomeEmptyStartScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RegisterDeviceScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.SelfDevicesScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeChooserScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.navigation.routes.media.AuthenticatedImportMediaRoute +import com.wire.android.navigation.routes.media.LoggedOutImportMediaRoute +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.settings.devices.SelfDevicesRoute +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.android.ui.userprofile.toQualifiedId import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.WireRoute import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -77,8 +55,18 @@ import dev.zacsweers.metro.SingleIn @SingleIn(AppScope::class) class CurrentScreenManager @Inject constructor( screenStateObserver: ScreenStateObserver -) : DefaultLifecycleObserver, - NavController.OnDestinationChangedListener { +) : DefaultLifecycleObserver { + + private var stopAnalyticsView: (String) -> Unit = AnonymousAnalyticsManagerImpl::stopView + private var recordAnalyticsView: (String) -> Unit = AnonymousAnalyticsManagerImpl::recordView + + internal constructor( + screenStateObserver: ScreenStateObserver, + anonymousAnalyticsManager: AnonymousAnalyticsManager, + ) : this(screenStateObserver) { + stopAnalyticsView = anonymousAnalyticsManager::stopView + recordAnalyticsView = anonymousAnalyticsManager::recordView + } private val currentScreenState = MutableStateFlow(CurrentScreen.SomeOther()) @@ -135,19 +123,14 @@ class CurrentScreenManager @Inject constructor( } } - override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) { - val currentScreenName = currentScreenName() - AnonymousAnalyticsManagerImpl.stopView(currentScreenName) - - val currentItem = destination.toDestination() - currentScreenState.value = CurrentScreen.fromDestination( - currentItem, - arguments, - isApplicationVisibleFlow.value - ) + fun onRouteChanged(route: WireRoute) { + changeCurrentScreen(CurrentScreen.fromRoute(route, isApplicationVisibleFlow.value)) + } - val newScreenName = currentScreenName() - AnonymousAnalyticsManagerImpl.recordView(newScreenName) + private fun changeCurrentScreen(newScreen: CurrentScreen) { + stopAnalyticsView(currentScreenName()) + currentScreenState.value = newScreen + recordAnalyticsView(currentScreenName()) } private fun currentScreenName() = currentScreenState.value.let { currentScreen -> @@ -229,47 +212,24 @@ sealed class CurrentScreen { open fun toScreenName(): String = "UnknownScreen" companion object { - @SuppressLint("RestrictedApi") - @Suppress("ComplexMethod") - fun fromDestination(destination: DestinationSpec?, arguments: Bundle?, isAppVisible: Boolean): CurrentScreen { + fun fromRoute(route: WireRoute, isAppVisible: Boolean): CurrentScreen { if (!isAppVisible) { return InBackground } - return when (destination) { - is HomeScreenDestination -> Home - is ConversationScreenDestination -> - Conversation(destination.argsFrom(arguments).conversationId) - - is OtherUserProfileScreenDestination -> - OtherUserProfile(destination.argsFrom(arguments).userId, destination.argsFrom(arguments).groupConversationId) - - is ImportMediaScreenDestination -> ImportMedia - - is SelfDevicesScreenDestination -> DeviceManager - - is WelcomeScreenDestination, - is NewWelcomeEmptyStartScreenDestination, - is WelcomeChooserScreenDestination, - is LoginScreenDestination, - is NewLoginScreenDestination, - is NewLoginPasswordScreenDestination, - is NewLoginVerificationCodeScreenDestination, - is CreatePersonalAccountOverviewScreenDestination, - is CreateTeamAccountOverviewScreenDestination, - is CreateAccountEmailScreenDestination, - is CreateAccountDetailsScreenDestination, - is CreateAccountSummaryScreenDestination, - is InitialSyncScreenDestination, - is E2EIEnrollmentScreenDestination, - is E2EiCertificateDetailsScreenDestination, - is RegisterDeviceScreenDestination, - is CreateAccountUsernameScreenDestination, - is CreateAccountVerificationCodeScreenDestination, - is CreateAccountDataDetailScreenDestination, - is CreateAccountSelectorScreenDestination, - is RemoveDeviceScreenDestination -> AuthRelated(destination.baseRoute) - - else -> SomeOther(destination?.baseRoute) + return when (route) { + is HomeRoute -> Home + is ConversationRoute -> Conversation( + ConversationId(route.conversationId.value, route.conversationId.domain) + ) + is AuthenticatedImportMediaRoute, + is LoggedOutImportMediaRoute -> ImportMedia + is OtherUserProfileRoute -> OtherUserProfile( + userId = route.targetUserId.toQualifiedId(), + groupConversationId = route.groupConversationId?.toQualifiedId(), + ) + is SelfDevicesRoute -> DeviceManager + is AuthenticationScreenRoute -> AuthRelated(route.routeId) + else -> SomeOther(route.routeId) } } } diff --git a/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt b/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt index 11f83b9bac7..29607d59a87 100644 --- a/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt +++ b/app/src/main/kotlin/com/wire/android/util/deeplink/DeepLinkProcessor.kt @@ -55,16 +55,22 @@ sealed class DeepLinkResult { data class OpenConversation( val conversationId: ConversationId, - val switchedAccount: Boolean = false + val switchedAccount: Boolean = false, + val targetSessionId: QualifiedID? = null, ) : DeepLinkResult() - data class OpenOtherUserProfile(val userId: QualifiedID, val switchedAccount: Boolean = false) : DeepLinkResult() + data class OpenOtherUserProfile( + val userId: QualifiedID, + val switchedAccount: Boolean = false, + val targetSessionId: QualifiedID? = null, + ) : DeepLinkResult() data class JoinConversation( val code: String, val key: String, val domain: String?, - val switchedAccount: Boolean = false + val switchedAccount: Boolean = false, + val targetSessionId: QualifiedID? = null, ) : DeepLinkResult() data class MigrationLogin(val userHandle: String) : DeepLinkResult() @@ -109,12 +115,19 @@ class DeepLinkProcessor @Inject constructor( return when (val switchStatus = switchAccountIfNeeded(uri, accountInfo)) { SwitchAccountStatus.Switched, SwitchAccountStatus.NoNeeded -> { + val targetSessionId = if (switchStatus == SwitchAccountStatus.Switched) { + uri.getQueryParameter(USER_TO_USE_QUERY_PARAM) + ?.toQualifiedID(qualifiedIdMapper) + } else { + null + } var deepLinkResult = handleNotAuthorizedDeepLinks(uri) if (deepLinkResult == DeepLinkResult.AuthorizationNeeded) { deepLinkResult = handleAuthorizedDeepLinks( uri = uri, accountInfo = accountInfo, - switchedAccount = switchStatus == SwitchAccountStatus.Switched + switchedAccount = switchStatus == SwitchAccountStatus.Switched, + targetSessionId = targetSessionId, ) } deepLinkResult @@ -136,12 +149,21 @@ class DeepLinkProcessor @Inject constructor( } } - private fun handleAuthorizedDeepLinks(uri: Uri, accountInfo: AccountInfo.Valid, switchedAccount: Boolean): DeepLinkResult { + private fun handleAuthorizedDeepLinks( + uri: Uri, + accountInfo: AccountInfo.Valid, + switchedAccount: Boolean, + targetSessionId: QualifiedID?, + ): DeepLinkResult { return when (uri.host) { - CONVERSATION_DEEPLINK_HOST -> getOpenConversationDeepLinkResult(uri, switchedAccount) - OTHER_USER_PROFILE_DEEPLINK_HOST -> getOpenOtherUserProfileDeepLinkResult(uri, switchedAccount) - JOIN_CONVERSATION_DEEPLINK_HOST -> getJoinConversationDeepLinkResult(uri, switchedAccount) - OPEN_USER_PROFILE_DEEPLINK_HOST -> getConnectingUserProfile(uri, switchedAccount, accountInfo) + CONVERSATION_DEEPLINK_HOST -> + getOpenConversationDeepLinkResult(uri, switchedAccount, targetSessionId) + OTHER_USER_PROFILE_DEEPLINK_HOST -> + getOpenOtherUserProfileDeepLinkResult(uri, switchedAccount, targetSessionId) + JOIN_CONVERSATION_DEEPLINK_HOST -> + getJoinConversationDeepLinkResult(uri, switchedAccount, targetSessionId) + OPEN_USER_PROFILE_DEEPLINK_HOST -> + getConnectingUserProfile(uri, switchedAccount, targetSessionId, accountInfo) else -> DeepLinkResult.Unknown } } @@ -149,10 +171,21 @@ class DeepLinkProcessor @Inject constructor( /** * Format of deeplink to parse: wire://user/domain/user-id */ - private fun getConnectingUserProfile(uri: Uri, switchedAccount: Boolean, accountInfo: AccountInfo.Valid): DeepLinkResult { - return when (val result = UserLinkQRMapper.fromDeepLinkToQualifiedId(uri, accountInfo.userId.domain)) { + private fun getConnectingUserProfile( + uri: Uri, + switchedAccount: Boolean, + targetSessionId: QualifiedID?, + accountInfo: AccountInfo.Valid, + ): DeepLinkResult { + val sessionDomain = targetSessionId?.domain ?: accountInfo.userId.domain + return when (val result = UserLinkQRMapper.fromDeepLinkToQualifiedId(uri, sessionDomain)) { is UserLinkQRMapper.UserLinkQRResult.Failure -> DeepLinkResult.Unknown - is UserLinkQRMapper.UserLinkQRResult.Success -> DeepLinkResult.OpenOtherUserProfile(result.qualifiedUserId, switchedAccount) + is UserLinkQRMapper.UserLinkQRResult.Success -> + DeepLinkResult.OpenOtherUserProfile( + result.qualifiedUserId, + switchedAccount, + targetSessionId, + ) } } @@ -181,18 +214,20 @@ class DeepLinkProcessor @Inject constructor( private fun getOpenConversationDeepLinkResult( uri: Uri, - switchedAccount: Boolean + switchedAccount: Boolean, + targetSessionId: QualifiedID?, ): DeepLinkResult = uri.lastPathSegment?.toQualifiedID(qualifiedIdMapper)?.let { conversationId -> - DeepLinkResult.OpenConversation(conversationId, switchedAccount) + DeepLinkResult.OpenConversation(conversationId, switchedAccount, targetSessionId) } ?: DeepLinkResult.Unknown private fun getOpenOtherUserProfileDeepLinkResult( uri: Uri, - switchedAccount: Boolean + switchedAccount: Boolean, + targetSessionId: QualifiedID?, ): DeepLinkResult = uri.lastPathSegment?.toQualifiedID(qualifiedIdMapper)?.let { - DeepLinkResult.OpenOtherUserProfile(it, switchedAccount) + DeepLinkResult.OpenOtherUserProfile(it, switchedAccount, targetSessionId) } ?: DeepLinkResult.Unknown private fun getOpenMigrationLoginDeepLinkResult(uri: Uri): DeepLinkResult = @@ -228,12 +263,16 @@ class DeepLinkProcessor @Inject constructor( } } - private fun getJoinConversationDeepLinkResult(uri: Uri, switchedAccount: Boolean): DeepLinkResult { + private fun getJoinConversationDeepLinkResult( + uri: Uri, + switchedAccount: Boolean, + targetSessionId: QualifiedID?, + ): DeepLinkResult { val code = uri.getQueryParameter(JOIN_CONVERSATION_CODE_PARAM) val key = uri.getQueryParameter(JOIN_CONVERSATION_KEY_PARAM) val domain = uri.getQueryParameter(JOIN_CONVERSATION_DOMAIN_PARAM) if (code == null || key == null) return DeepLinkResult.Unknown - return DeepLinkResult.JoinConversation(code, key, domain, switchedAccount) + return DeepLinkResult.JoinConversation(code, key, domain, switchedAccount, targetSessionId) } companion object { diff --git a/app/src/private/kotlin/com/wire/android/navigation/TrackingNavController.kt b/app/src/private/kotlin/com/wire/android/navigation/TrackingNavController.kt deleted file mode 100644 index d6f01ea310a..00000000000 --- a/app/src/private/kotlin/com/wire/android/navigation/TrackingNavController.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.wire.android.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavDestination -import androidx.navigation.compose.rememberNavController -import com.datadog.android.compose.ExperimentalTrackingApi -import com.datadog.android.compose.NavigationViewTrackingEffect -import com.datadog.android.rum.tracking.AcceptAllNavDestinations - -@OptIn(ExperimentalTrackingApi::class) -@Composable -fun rememberTrackingAnimatedNavController(nameFromRoute: (String) -> String?) = - rememberNavController().apply { - NavigationViewTrackingEffect( - navController = this, - trackArguments = true, - destinationPredicate = object : AcceptAllNavDestinations() { - override fun getViewName(component: NavDestination): String? = - component.route?.let { nameFromRoute(it) } - } - ) - } diff --git a/app/src/public/kotlin/com/wire/android/navigation/TrackingNavController.kt b/app/src/public/kotlin/com/wire/android/navigation/TrackingNavController.kt deleted file mode 100644 index 75bce1ecc2e..00000000000 --- a/app/src/public/kotlin/com/wire/android/navigation/TrackingNavController.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.wire.android.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.compose.rememberNavController - -@Composable -fun rememberTrackingAnimatedNavController(nameFromRoute: (String) -> String?) = - rememberNavController() diff --git a/app/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt b/app/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt deleted file mode 100644 index fbfb5370cf2..00000000000 --- a/app/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.config - -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext - -/** - * This extension provides a way to mock navigation arguments getters. - * It can be used to mock getting navigation arguments from savedStateHandle, like: savedStateHandle.navArgs() - * or to get arguments from the specific Destination object, like: ExampleScreenDestination.argsFrom(savedStateHandle). - * - * Add this JUnit 5 extension to your test class using - * @JvmField - * @RegisterExtension - * val navigationTestExtension = NavigationTestExtension() - * - * or: - * - * Annotating the class with - * @ExtendWith(NavigationTestExtension::class) - */ -class NavigationTestExtension : BeforeEachCallback, AfterEachCallback { - override fun beforeEach(context: ExtensionContext?) { - mockkStatic("com.ramcosta.composedestinations.generated.app.NavArgsGettersKt") - } - - override fun afterEach(context: ExtensionContext?) { - unmockkStatic("com.ramcosta.composedestinations.generated.app.NavArgsGettersKt") - } -} diff --git a/app/src/test/kotlin/com/wire/android/di/metro/ScopedMessageManualViewModelFactoryTest.kt b/app/src/test/kotlin/com/wire/android/di/metro/ScopedMessageManualViewModelFactoryTest.kt index d4710d0f97c..a74ab56445d 100644 --- a/app/src/test/kotlin/com/wire/android/di/metro/ScopedMessageManualViewModelFactoryTest.kt +++ b/app/src/test/kotlin/com/wire/android/di/metro/ScopedMessageManualViewModelFactoryTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.di.metro -import androidx.lifecycle.SavedStateHandle import com.wire.android.media.audiomessage.AudioMessageArgs import com.wire.android.media.audiomessage.AudioMessageViewModelImpl import com.wire.android.ui.home.conversations.CompositeMessageViewModelImpl @@ -45,19 +44,18 @@ import org.junit.jupiter.api.Test class ScopedMessageManualViewModelFactoryTest { @Test - fun givenCompositeArguments_whenCreatingViewModel_thenDelegatesExactSavedStateHandleAndArguments() { + fun givenCompositeArguments_whenCreatingViewModel_thenDelegatesExactArguments() { val (arrangement, factory) = Arrangement().arrange() - val savedStateHandle = SavedStateHandle() val args = mockk() every { - arrangement.compositeMessageFactory.create(savedStateHandle, args) + arrangement.compositeMessageFactory.create(args) } returns arrangement.compositeMessageViewModel - val result = factory.compositeMessageViewModel(savedStateHandle, args) + val result = factory.compositeMessageViewModel(args) assertSame(arrangement.compositeMessageViewModel, result) verify(exactly = 1) { - arrangement.compositeMessageFactory.create(savedStateHandle, args) + arrangement.compositeMessageFactory.create(args) } } diff --git a/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt deleted file mode 100644 index 08c45f99500..00000000000 --- a/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation - -import com.wire.android.model.ImageAsset -import com.wire.android.model.parseIntoPrivateImageAsset -import com.wire.kalium.logic.data.id.QualifiedID -import com.wire.kalium.logic.data.id.QualifiedIdMapper -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -internal class NavigationUtilsTest { - - @Test - fun `Given some correct string, when calling parseIntoPrivateImageAsset, then it correctly parses it to PrivateImageAsset`() { - val qualifiedIdMapper = QualifiedIdMapper(null) - - // Given - val mockConversationIdValue = "mocked-conversation-id-value" - val mockConversationIdDomain = "mocked.domain" - val mockMessageId = "mocked-message-id" - val mockIsSelfAsset = "true" - val mockIsEphemeral = "true" - - val correctImagePrivateAssetString = - "$mockConversationIdValue@$mockConversationIdDomain:$mockMessageId:$mockIsSelfAsset:$mockIsEphemeral" - - // When - val privateImgAsset = correctImagePrivateAssetString.parseIntoPrivateImageAsset(qualifiedIdMapper) - - // Then - assertEquals(privateImgAsset.conversationId.value, mockConversationIdValue) - assertEquals(privateImgAsset.conversationId.domain, mockConversationIdDomain) - assertEquals(privateImgAsset.messageId, mockMessageId) - assertEquals(privateImgAsset.isSelfAsset, mockIsSelfAsset.toBoolean()) - assertEquals(privateImgAsset.isEphemeral, mockIsEphemeral.toBoolean()) - } - - @Test - fun `Given an incorrect string, when parsing it to PrivateImageAsset, then it throws an exception`() { - val qualifiedIdMapper = QualifiedIdMapper(null) - - // Given - val mockWrongImagePrivateAssetString = "wrong-private-asset@image" - - // When, Then - assertThrows { mockWrongImagePrivateAssetString.parseIntoPrivateImageAsset(qualifiedIdMapper) } - } - - @Test - fun `Given some correct QualifiedId object, it parses it correctly to string`() { - // Given - val mockQualifiedIdValue = "mocked-value" - val mockQualifiedIdDomain = "mocked.domain" - val actualQualifiedId = QualifiedID(value = mockQualifiedIdValue, domain = mockQualifiedIdDomain) - val expectedQualifiedID = "$mockQualifiedIdValue@$mockQualifiedIdDomain" - - // When - val mappedQualifiedId = actualQualifiedId.toString() - - // Then - assertEquals(mappedQualifiedId, expectedQualifiedID) - } - - @Test - fun `Given some correct Image PrivateAsset object, it parses it correctly to string`() { - // Given - val mockQualifiedIdValue = "mocked-value" - val mockQualifiedIdDomain = "mocked.domain" - val mockMessageId = "mocked-message-id" - val actualPrivateAssetImage = ImageAsset.PrivateAsset( - conversationId = QualifiedID( - value = mockQualifiedIdValue, - domain = mockQualifiedIdDomain - ), - messageId = mockMessageId, - isSelfAsset = true, - isEphemeral = true - ) - val expectedPrivateAssetImage = "$mockQualifiedIdValue@$mockQualifiedIdDomain:$mockMessageId:true:true" - - // When - val mappedImagePrivateAsset = actualPrivateAssetImage.toString() - - // Then - assertEquals(mappedImagePrivateAsset, expectedPrivateAssetImage) - } - - @Test - fun `given route without segments and parameters, when getting primary route, then return only host part which is the same route`() { - // Given - val route = "route" - // When - val result = route.getBaseRoute() - // Then - assertEquals(route, result) - } - - @Test - fun `given route with segments but without parameters, when getting primary route, then return host with first segment`() { - // Given - val route = "route/segment1/segment2" - // When - val result = route.getBaseRoute() - // Then - assertEquals("route/segment1", result) - } - - @Test - fun `given route without segments but with parameters, when getting primary route, then return only host part`() { - // Given - val host = "route" - val route = "$host?param1=value1¶m2=value2" - // When - val result = route.getBaseRoute() - // Then - assertEquals(host, result) - } - - @Test - fun `given route with segments and parameters, when getting primary route, then return host with first segment`() { - // Given - val route = "route/segment1/segment2?param1=value1¶m2=value2" - // When - val result = route.getBaseRoute() - // Then - assertEquals("route/segment1", result) - } - - @Test - fun `given empty string, when getting base route, then return empty string`() { - // Given - val route = "" - // When - val result = route.getBaseRoute() - // Then - assertEquals("", result) - } - - @Test - fun `given route with single segment, when getting base route, then return full route`() { - // Given - val route = "route/segment" - // When - val result = route.getBaseRoute() - // Then - assertEquals("route/segment", result) - } - - @Test - fun `given route with single segment and parameters, when getting base route, then return route with segment`() { - // Given - val route = "route/segment?param=value" - // When - val result = route.getBaseRoute() - // Then - assertEquals("route/segment", result) - } - - @Test - fun `given route where query param appears before second slash, when getting base route, then stop at query param`() { - // Given - val route = "route/segment?param=value/other" - // When - val result = route.getBaseRoute() - // Then - assertEquals("route/segment", result) - } -} diff --git a/app/src/test/kotlin/com/wire/android/navigation/TabletDialogRoutePolicyTest.kt b/app/src/test/kotlin/com/wire/android/navigation/TabletDialogRoutePolicyTest.kt deleted file mode 100644 index faddcb91908..00000000000 --- a/app/src/test/kotlin/com/wire/android/navigation/TabletDialogRoutePolicyTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation - -import com.wire.android.navigation.style.AuthSlideNavigationAnimation -import com.wire.android.navigation.style.DialogNavigation -import com.wire.android.navigation.style.SlideNavigationAnimation -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -internal class TabletDialogRoutePolicyTest { - - @Test - fun `given route policy, when checking members, then contains all 17 pre migration routes`() { - val expectedRoutes = setOf( - "app/service_details_screen", - "app/other_user_profile_screen", - "app/self_user_profile_screen", - "app/device_details_screen", - "app/change_display_name_screen", - "app/change_handle_screen", - "app/change_email_screen", - "app/avatar_picker_screen", - "app/group_conversation_details_screen", - "app/edit_conversation_name_screen", - "app/edit_guest_access_screen", - "app/update_apps_access_screen", - "app/channel_access_on_update_screen", - "app/edit_self_deleting_messages_screen", - "app/conversation_folders_screen", - "app/new_conversation_folder_screen", - "app/change_user_color_screen", - ) - - assertEquals(17, TabletDialogRoutePolicy.destinationBaseRoutes.size) - assertEquals(expectedRoutes, TabletDialogRoutePolicy.destinationBaseRoutes) - } - - @Test - fun `given tablet and listed route, when resolving style, then dialog style is used`() { - val resolvedStyle = resolveTabletDialogParityStyle( - destinationRoute = "app/change_user_color_screen/{arg}?foo=bar", - originalStyle = SlideNavigationAnimation, - manualAnimation = null, - isTablet = true, - ) - - assertEquals(DialogNavigation, resolvedStyle) - } - - @Test - fun `given phone and listed route, when resolving style, then original phone style is used`() { - val resolvedStyle = resolveTabletDialogParityStyle( - destinationRoute = "app/change_user_color_screen/{arg}", - originalStyle = SlideNavigationAnimation, - manualAnimation = null, - isTablet = false, - ) - - assertEquals(SlideNavigationAnimation, resolvedStyle) - } - - @Test - fun `given non listed route, when resolving style, then parity does not force dialog and manual animation is respected`() { - assertFalse(TabletDialogRoutePolicy.shouldShowAsDialog("app/non_listed_screen")) - assertTrue(TabletDialogRoutePolicy.shouldShowAsDialog("app/edit_guest_access_screen")) - - val resolvedStyle = resolveTabletDialogParityStyle( - destinationRoute = "app/non_listed_screen/{id}", - originalStyle = SlideNavigationAnimation, - manualAnimation = AuthSlideNavigationAnimation, - isTablet = true, - ) - - assertEquals(AuthSlideNavigationAnimation, resolvedStyle) - } -} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/Navigation3ContractSourcePurityTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/Navigation3ContractSourcePurityTest.kt new file mode 100644 index 00000000000..4a5060d071c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/Navigation3ContractSourcePurityTest.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.routes + +import java.nio.file.Files +import java.nio.file.Path +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class Navigation3ContractSourcePurityTest { + + @Test + fun givenKmpReadyNavigationContracts_whenInspectingImports_thenOnlyCommonDependenciesAreUsed() { + contractSources.forEach { relativePath -> + val source = Files.readAllLines(Path.of("src/main/kotlin").resolve(relativePath)) + val unsupportedImports = source + .filter { it.startsWith("import ") } + .map { it.removePrefix("import ").substringBefore(" as ") } + .filterNot { importedType -> + importedType.startsWith("kotlin.") || + importedType.startsWith("kotlinx.serialization.") || + importedType.startsWith("com.wire.navigation.") + } + + assertTrue( + unsupportedImports.isEmpty(), + "$relativePath has platform or legacy imports: $unsupportedImports", + ) + } + } + + private companion object { + val contractSources = listOf( + "com/wire/android/navigation/routes/auth/AuthenticationRoutes.kt", + "com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt", + "com/wire/android/ui/authentication/devices/register/RegisterDeviceRoute.kt", + "com/wire/android/ui/authentication/devices/remove/RemoveDeviceRoute.kt", + "com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentRoute.kt", + "com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3.kt", + "com/wire/android/ui/home/settings/SettingsNavigation3.kt", + "com/wire/android/ui/settings/devices/DeviceManagementNavigation3.kt", + "com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3.kt", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3EntriesTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3EntriesTest.kt new file mode 100644 index 00000000000..3164b913b7e --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3EntriesTest.kt @@ -0,0 +1,118 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.android.ui.newauthentication.login.NewLoginAction +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AuthenticationNavigation3EntriesTest { + + @Test + fun givenAuthenticationContribution_whenInspectingEntries_thenAllSevenRoutesAreRegistered() { + val source = sourceFile("AuthenticationNavigation3Entries.kt").readText() + + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + ).forEach { registration -> + assertTrue(source.contains(registration), "Missing $registration") + } + } + + @Test + fun givenAuthenticationEntries_whenInspectingSource_thenTheyDoNotDependOnLegacyNavigationRuntime() { + val source = sourceFile("AuthenticationNavigation3Entries.kt").readText() + + assertFalse(source.contains("ScreenDestination")) + assertFalse(source.contains("com.ramcosta.composedestinations")) + assertFalse(source.contains("NavController")) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("Bundle")) + assertFalse(source.contains("navArgs()")) + } + + @Test + fun givenArgumentBackedAuthenticationViewModels_whenInspectingSources_thenTypedArgsUseFocusedAssistedFactories() { + val graph = sourceFile( + "../../../ui/authentication/AuthenticationViewModelGraph.kt" + ).readText() + val bindings = sourceFile( + "../../../di/metro/AuthenticationMetroViewModelBindings.kt" + ).readText() + val welcomeViewModel = sourceFile( + "../../../ui/authentication/welcome/WelcomeViewModel.kt" + ).readText() + val newLoginViewModel = sourceFile( + "../../../ui/newauthentication/login/NewLoginViewModel.kt" + ).readText() + val entries = sourceFile("AuthenticationNavigation3Entries.kt").readText() + + assertTrue(graph.contains("fun welcomeViewModel(navArgs: WelcomeNavArgs)")) + assertTrue(graph.contains("fun newLoginViewModel(loginNavArgs: LoginNavArgs, extras: CreationExtras)")) + assertTrue(bindings.contains("WelcomeViewModel.Factory")) + assertTrue(bindings.contains("NewLoginViewModel.Factory")) + assertTrue(bindings.contains("welcomeFactory.create(navArgs)")) + assertTrue(bindings.contains("newLoginFactory.create(loginNavArgs, extras.createSavedStateHandle())")) + assertTrue(welcomeViewModel.contains("interface Factory")) + assertTrue(welcomeViewModel.contains("fun create(navArgs: WelcomeNavArgs): WelcomeViewModel")) + assertTrue(newLoginViewModel.contains("fun create(loginNavArgs: LoginNavArgs, savedStateHandle: SavedStateHandle)")) + assertTrue(entries.contains("welcomeViewModel(route.toLegacyNavArgs(), flowOwner)")) + assertTrue(entries.contains("newLoginViewModel(legacyArgs, flowOwner)")) + assertTrue(entries.contains("WireViewModelOwner.Flow(route.flowId)")) + assertTrue(entries.contains("NewLoginVerificationCodeRouteScreen")) + assertTrue(Regex("WireViewModelOwner\\.Flow\\(route\\.flowId\\)").findAll(entries).count() >= 5) + assertFalse(entries.contains("openVerificationCode")) + } + + @Test + fun givenWelcomeChooserEntry_whenBuildingNewLoginFlowId_thenItIsStableAndEntrySpecific() { + val route = WelcomeChooserRoute(entryId = WireNavEntryId("chooser-entry")) + + assertEquals("new-login:chooser-entry", route.newLoginFlowId()) + assertEquals(route.newLoginFlowId(), route.newLoginFlowId()) + } + + @Test + fun givenNewLoginCompletionSteps_whenMappingToHostBoundary_thenTheirMeaningIsPreserved() { + val userId = com.wire.kalium.logic.data.user.UserId("user", "wire.test") + val sessionId = WireSessionId("user", "wire.test") + assertEquals( + AuthenticationLoginCompletion.Home(sessionId), + NewLoginAction.Success.NextStep.None(userId).toAuthenticationLoginCompletion(), + ) + assertEquals( + AuthenticationLoginCompletion.InitialSync(sessionId), + NewLoginAction.Success.NextStep.InitialSync(userId).toAuthenticationLoginCompletion(), + ) + } + + private fun sourceFile(name: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + projectDir, + "app/src/main/kotlin/com/wire/android/navigation/routes/auth/$name", + ).also { + assertTrue(it.isFile, "Missing source file $name") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterSourceTest.kt new file mode 100644 index 00000000000..47da480490e --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterSourceTest.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 or any later version. + */ + +package com.wire.android.navigation.routes.auth + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AuthenticationNavigation3RouterSourceTest { + + @Test + fun givenAuthenticationEntryProviders_whenInspectingMutations_thenOnlyRouterChangesTheStack() { + authenticationProviderSources.forEach { source -> + val completeContent = projectSource(source).readText() + val content = if (source.endsWith("DeviceE2EINavigation3Entries.kt")) { + completeContent.substringBefore("private fun SelfDevicesNavigation3Entry") + } else { + completeContent + } + + listOf( + "runtime.navigator.navigate(", + "runtime.navigator.goBack(", + "runtime.navigator.replaceBackStack(", + ).forEach { forbidden -> + assertFalse( + forbidden in content, + "$source must dispatch authentication transitions through AuthenticationNavigation3Router", + ) + } + } + } + + @Test + fun givenExecutableTopology_whenInspectingTransitions_thenEveryNamedEdgeIsUsed() { + val router = projectSource(routerSource).readText() + val productionSources = buildString { + append(router) + authenticationProviderSources.forEach { append(projectSource(it).readText()) } + append(projectSource("app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt").readText()) + append(projectSource("app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Effects.kt").readText()) + append(projectSource("app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffects.kt").readText()) + append(projectSource("app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt").readText()) + } + val transitionNames = Regex("""^\s{4}([A-Z][A-Z0-9_]+),?$""", RegexOption.MULTILINE) + .findAll( + router.substringAfter("internal enum class AuthenticationNavigationTransition") + .substringBefore("\n}"), + ) + .map { it.groupValues[1] } + .toList() + + assertTrue(transitionNames.isNotEmpty()) + transitionNames.forEach { transition -> + assertTrue( + "AuthenticationNavigationTransition.$transition" in productionSources, + "Authentication topology edge $transition is declared but not executable", + ) + } + } + + private fun projectSource(relativePath: String): File = + generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .map { File(it, relativePath) } + .first(File::isFile) + + private companion object { + const val routerSource = + "app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt" + val authenticationProviderSources = listOf( + "app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt", + "app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt", + "app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt new file mode 100644 index 00000000000..223662e9726 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt @@ -0,0 +1,231 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.android.ui.authentication.login.DomainClaimedByOrg +import com.wire.android.ui.authentication.login.LoginNavArgs +import com.wire.android.ui.authentication.login.LoginPasswordPath +import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType +import com.wire.android.ui.authentication.login.SSOCodeAutoLogin +import com.wire.android.ui.authentication.welcome.WelcomeNavArgs +import com.wire.android.util.deeplink.DeepLinkResult +import com.wire.android.util.deeplink.SSOFailureCodes +import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.navigation.WireNavEntryId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class AuthenticationRoutesTest { + + @Test + fun givenNewLoginStartFactory_whenCreatingRoute_thenEntryAndFlowOwnershipMatch() { + val route = NewLoginRoute.start() + + assertEquals(route.entryId.value, route.flowId) + } + + @Test + fun givenLegacyAuthenticationRoots_whenCreatingRoutes_thenEntryStartsFlowOwnership() { + val welcome = WelcomeRoute(entryId = WireNavEntryId("welcome-entry")) + val login = LoginRoute(entryId = WireNavEntryId("login-entry")) + + assertEquals("welcome-entry", welcome.flowId) + assertEquals("login-entry", login.flowId) + } + + @Test + fun givenAuthenticationRoutes_whenReadingRouteIds_thenGeneratedBaseRoutesArePreserved() { + assertEquals("app/welcome_chooser_screen", WelcomeChooserRoute.ROUTE_ID) + assertEquals("app/new_welcome_empty_start_screen", NewWelcomeEmptyStartRoute.ROUTE_ID) + assertEquals("app/welcome_screen", WelcomeRoute.ROUTE_ID) + assertEquals("app/login_screen", LoginRoute.ROUTE_ID) + assertEquals("app/new_login_screen", NewLoginRoute.ROUTE_ID) + assertEquals("app/new_login_password_screen", NewLoginPasswordRoute.ROUTE_ID) + assertEquals( + "app/new_login_verification_code_screen", + NewLoginVerificationCodeRoute.ROUTE_ID, + ) + } + + @Test + fun givenSameAuthenticationDestination_whenCreatingTwoRoutes_thenEntryIdsAreDifferent() { + val first = LoginRoute() + val second = LoginRoute() + + assertEquals(first.routeId, second.routeId) + assertNotEquals(first.entryId, second.entryId) + } + + @Test + fun givenNewLoginRoute_whenSerializedAndRestored_thenArgumentsAndOwnershipArePreserved() { + val route = NewLoginRoute( + args = completeLegacyLoginArguments().toAuthenticationArguments(), + flowId = "new-login-flow", + entryId = WireNavEntryId("new-login-entry"), + ) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals("new-login-flow", restored.flowId) + assertEquals("new-login-entry", restored.entryId.value) + assertEquals("app/new_login_screen", restored.routeId) + } + + @Test + fun givenLegacyLoginArguments_whenMappedToTypedAndBack_thenEveryValueIsPreserved() { + val legacy = completeLegacyLoginArguments() + + val restored = legacy.toAuthenticationArguments().toLegacy() + + assertEquals(legacy, restored) + } + + @Test + fun givenEveryLegacySsoFailure_whenMappedToTypedAndBack_thenFailureIsPreserved() { + SSOFailureCodes.values().forEach { failure -> + val legacy = LoginNavArgs( + ssoLoginResult = DeepLinkResult.SSOLogin.Failure(failure) + ) + + assertEquals(legacy, legacy.toAuthenticationArguments().toLegacy()) + } + } + + @Test + fun givenLegacyLoginArguments_whenCreatingEachLoginRoute_thenRouteMappersRoundTrip() { + val legacy = completeLegacyLoginArguments() + + assertEquals( + legacy, + legacy.toLoginRoute(WireNavEntryId("legacy-login-entry")).toLegacyNavArgs(), + ) + assertEquals( + legacy, + legacy.toNewLoginRoute( + flowId = "new-login-flow", + entryId = WireNavEntryId("new-login-entry"), + ).toLegacyNavArgs(), + ) + assertEquals( + legacy, + legacy.toNewLoginPasswordRoute( + flowId = "new-login-flow", + entryId = WireNavEntryId("password-entry"), + ).toLegacyNavArgs(), + ) + assertEquals( + legacy, + legacy.toNewLoginVerificationCodeRoute( + flowId = "new-login-flow", + entryId = WireNavEntryId("verification-entry"), + ).toLegacyNavArgs(), + ) + assertEquals( + "shared-legacy-flow", + legacy.toLoginRoute( + entryId = WireNavEntryId("legacy-login-entry"), + flowId = "shared-legacy-flow", + ).flowId, + ) + } + + @Test + fun givenWelcomeArguments_whenMappedToTypedSerializedAndBack_thenServerLinksArePreserved() { + val legacy = WelcomeNavArgs(customServerConfig = SERVER_LINKS) + val route = legacy.toWelcomeRoute(WireNavEntryId("welcome-entry")) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + .toLegacyNavArgs() + + assertEquals(legacy.customServerConfig, restored.customServerConfig) + } + + @Test + fun givenBlankFlowId_whenCreatingNewLoginRoute_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + NewLoginRoute(flowId = "") + } + assertThrows(IllegalArgumentException::class.java) { + NewLoginPasswordRoute( + args = AuthenticationLoginArguments(), + flowId = " ", + ) + } + assertThrows(IllegalArgumentException::class.java) { + NewLoginVerificationCodeRoute( + args = AuthenticationLoginArguments(), + flowId = "", + ) + } + } + + @Test + fun givenBlankClaimedDomain_whenCreatingTypedLoginArguments_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + AuthenticationDomainClaim.Claimed("") + } + } + + private fun completeLegacyLoginArguments() = LoginNavArgs( + userHandle = PreFilledUserIdentifierType.PreFilled( + userIdentifier = "alice@example.com", + editable = true, + ), + ssoLoginResult = DeepLinkResult.SSOLogin.Success( + cookie = "cookie", + serverConfigId = "backend-id", + ), + loginPasswordPath = LoginPasswordPath( + customServerConfig = SERVER_LINKS, + isCloudAccountCreationPossible = false, + isDomainClaimedByOrg = DomainClaimedByOrg.Claimed("example.com"), + ), + ssoCodeAutoLogin = SSOCodeAutoLogin( + ssoCode = "sso-code", + autoInitiateLogin = false, + nomadServiceUrl = "https://nomad.example.com", + cookieLabel = "wire-cookie", + ), + showBackendConfigSuccess = true, + ) + + private companion object { + val SERVER_LINKS = ServerConfig.Links( + api = "https://api.example.com", + accounts = "https://accounts.example.com", + webSocket = "wss://websocket.example.com", + blackList = "https://blacklist.example.com", + teams = "https://teams.example.com", + website = "https://www.example.com", + title = "Example", + isOnPremises = true, + apiProxy = ServerConfig.ApiProxy( + needsAuthentication = true, + host = "proxy.example.com", + port = 8443, + ), + supportEmail = "support@example.com", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationTransitionLedgerTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationTransitionLedgerTest.kt new file mode 100644 index 00000000000..3a1cfdbc816 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationTransitionLedgerTest.kt @@ -0,0 +1,125 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.navigation.routes.auth + +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class AuthenticationTransitionLedgerTest { + + @Test + fun givenEmptyLoginIsTheOnlyRoute_whenActivityRequestsLogin_thenItIsCurrentRoot() { + val route = NewLoginRoute.start() + + assertEquals(true, listOf(route).hasActiveAuthenticationFlow()) + } + + @Test + fun givenLoginContainsIntentArguments_whenActivityRequestsLogin_thenItRemainsCurrentRoot() { + val route = NewLoginRoute.start( + AuthenticationLoginArguments( + userHandle = AuthenticationPrefilledUserIdentifier("wire-user"), + ) + ) + + assertEquals(true, listOf(route).hasActiveAuthenticationFlow()) + } + + @Test + fun givenPasswordIsCurrentInLoginFlow_whenActivityRequestsLogin_thenFlowRemainsActive() { + val login = NewLoginRoute.start() + + assertEquals(true, listOf(login, NewLoginPasswordRoute(login.args, login.flowId)).hasActiveAuthenticationFlow()) + } + + @Test + fun givenChooserPrecedesLogin_whenActivityRequestsLoginAgain_thenFlowRemainsActive() { + val login = NewLoginRoute.start() + + assertEquals( + true, + listOf(WelcomeChooserRoute(), login).hasActiveAuthenticationFlow(), + ) + } + + @Test + fun givenEventWasApplied_whenSameEventRunsAgain_thenStackActionRunsOnlyOnce() { + val ledger = AuthenticationTransitionLedger() + var calls = 0 + + val first = ledger.executeOnce("entry:terminal") { + calls++ + true + } + val duplicate = ledger.executeOnce("entry:terminal") { + calls++ + true + } + + assertEquals(AuthenticationTransitionLedger.Outcome.APPLIED, first) + assertEquals(AuthenticationTransitionLedger.Outcome.ALREADY_APPLIED, duplicate) + assertEquals(1, calls) + } + + @Test + fun givenEventWasRejected_whenSameEventRunsAgain_thenItCanBeRetried() { + val ledger = AuthenticationTransitionLedger() + var accepted = false + + val rejected = ledger.executeOnce("entry:terminal") { accepted } + accepted = true + val retried = ledger.executeOnce("entry:terminal") { accepted } + + assertEquals(AuthenticationTransitionLedger.Outcome.REJECTED, rejected) + assertEquals(AuthenticationTransitionLedger.Outcome.APPLIED, retried) + } + + @Test + fun givenBlankEventId_whenExecuting_thenContractFailsFast() { + val ledger = AuthenticationTransitionLedger() + + assertThrows(IllegalArgumentException::class.java) { + ledger.executeOnce("") { true } + } + } + + @Test + fun givenConcurrentCollectors_whenTheyApplySameTerminalEvent_thenOnlyOneMutatesNavigation() { + val ledger = AuthenticationTransitionLedger() + val actionCalls = AtomicInteger() + val executor = Executors.newFixedThreadPool(8) + + val outcomes = try { + executor.invokeAll( + List(64) { + Callable { + ledger.executeOnce("entry:concurrent-terminal") { + actionCalls.incrementAndGet() + true + } + } + } + ).map { it.get() } + } finally { + executor.shutdownNow() + } + + assertEquals(1, actionCalls.get()) + assertEquals(1, outcomes.count { it == AuthenticationTransitionLedger.Outcome.APPLIED }) + assertEquals( + 63, + outcomes.count { it == AuthenticationTransitionLedger.Outcome.ALREADY_APPLIED }, + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Test.kt new file mode 100644 index 00000000000..0e55b9f972b --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Test.kt @@ -0,0 +1,166 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs +import com.wire.android.ui.authentication.create.common.UserRegistrationInfo +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import java.io.File +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CreateAccountNavigation3Test { + + @Test + fun givenUsernameStartFactory_whenCreatingRoute_thenEntryAndFlowOwnershipMatch() { + val route = CreateAccountUsernameRoute.start(WireSessionId("user", "wire.test")) + + assertEquals(route.entryId.value, route.flowId) + } + + @Test + fun givenCreateAccountRoutes_whenReadingRouteIds_thenGeneratedBaseRoutesArePreserved() { + assertEquals("app/create_account_selector_screen", CreateAccountSelectorRoute.ROUTE_ID) + assertEquals("app/create_account_data_detail_screen", CreateAccountDataDetailRoute.ROUTE_ID) + assertEquals( + "app/create_account_verification_code_screen", + CreateAccountVerificationCodeRoute.ROUTE_ID, + ) + assertEquals( + "app/create_personal_account_overview_screen", + CreatePersonalAccountOverviewRoute.ROUTE_ID, + ) + assertEquals( + "app/create_team_account_overview_screen", + CreateTeamAccountOverviewRoute.ROUTE_ID, + ) + assertEquals("app/create_account_email_screen", CreateAccountEmailRoute.ROUTE_ID) + assertEquals("app/create_account_details_screen", CreateAccountDetailsRoute.ROUTE_ID) + assertEquals("app/create_account_code_screen", CreateAccountCodeRoute.ROUTE_ID) + assertEquals("app/create_account_summary_screen", CreateAccountSummaryRoute.ROUTE_ID) + assertEquals("app/create_account_username_screen", CreateAccountUsernameRoute.ROUTE_ID) + } + + @Test + fun givenRegistrationRoute_whenSerializedAndRestored_thenSensitiveFlowStateAndOwnershipArePreserved() { + val route = CreateAccountVerificationCodeRoute( + registrationInfo = CreateAccountRegistrationInfo( + email = "alice@example.com", + name = "Alice", + password = "secret", + ), + flowId = "registration-flow", + entryId = WireNavEntryId("verification-entry"), + ) + + val restored = Json.decodeFromString( + Json.encodeToString(route) + ) + + assertEquals(route, restored) + } + + @Test + fun givenLegacyRegistrationArguments_whenMappedToTypedAndBack_thenEveryValueIsPreserved() { + val legacy = CreateAccountDataNavArgs( + userRegistrationInfo = UserRegistrationInfo( + email = "alice@example.com", + name = "Alice", + password = "secret", + ) + ) + + val restored = legacy.toDataDetailRoute("registration-flow").toLegacyNavArgs() + + assertEquals(legacy, restored) + } + + @Test + fun givenBlankFlowId_whenCreatingAnyCreateAccountRoute_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + CreateAccountSelectorRoute(flowId = " ") + } + assertThrows(IllegalArgumentException::class.java) { + CreateAccountUsernameRoute( + sessionId = WireSessionId("user", "wire.test"), + flowId = "", + ) + } + } + + @Test + fun givenCreateAccountContribution_whenInspectingEntries_thenAllTenRoutesAreRegistered() { + val source = sourceFile("CreateAccountNavigation3Entries.kt").readText() + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + ).forEach { + assertTrue(source.contains(it), "Missing $it") + } + } + + @Test + fun givenCreateAccountEntries_whenInspectingSource_thenScreenViewModelsUseEntryOwnershipWithoutNav2Bridges() { + val source = sourceFile("CreateAccountNavigation3Entries.kt").readText() + + assertTrue(source.contains("wireViewModelStoreOwner(WireViewModelOwner.Entry(entryId))")) + assertTrue(source.contains("route.flowId")) + assertFalse(source.contains("WireViewModelOwner.Flow")) + assertFalse(source.contains("ScreenDestination")) + assertFalse(source.contains("com.ramcosta.composedestinations")) + assertFalse(source.contains("NavController")) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("Bundle")) + assertFalse(source.contains("DEFAULT_ARGS_KEY")) + } + + @Test + fun givenArgumentBackedRegistrationViewModels_whenInspectingFactory_thenEveryTypedArgumentHasManualBinding() { + val graph = sourceFile("../../../ui/authentication/AuthenticationViewModelGraph.kt").readText() + listOf( + "createAccountOverviewViewModel(navArgs: CreateAccountOverviewNavArgs)", + "createAccountEmailViewModel(navArgs: CreateAccountNavArgs)", + "createAccountDetailsViewModel(navArgs: CreateAccountNavArgs)", + "createAccountCodeViewModel(navArgs: CreateAccountNavArgs)", + "createAccountSummaryViewModel(navArgs: CreateAccountSummaryNavArgs)", + "createAccountSelectorViewModel(navArgs: CreateAccountSelectorNavArgs)", + "createAccountDataDetailViewModel(navArgs: CreateAccountDataNavArgs)", + ).forEach { + assertTrue(graph.contains(it), "Missing manual assisted method $it") + } + assertTrue(graph.contains("fun createAccountVerificationCodeViewModel(")) + } + + private fun sourceFile(name: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + projectDir, + "app/src/main/kotlin/com/wire/android/navigation/routes/auth/$name", + ).also { + assertTrue(it.isFile, "Missing source file $name") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3SourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3SourceTest.kt new file mode 100644 index 00000000000..ed26776632e --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3SourceTest.kt @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ +package com.wire.android.navigation.routes.media + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MediaNavigation3SourceTest { + @Test + fun givenMediaContracts_whenInspectingImports_thenTheyStayKmpPure() { + val source = source("navigation/routes/media/MediaRoutes.kt") + listOf("android.", "androidx.", "com.ramcosta.", "com.wire.kalium.").forEach { + assertFalse(source.lineSequence().any { line -> line.startsWith("import $it") }) + } + listOf("Bundle", "SavedStateHandle", "Parcelable", "NavArgs").forEach { + assertFalse(source.contains(it)) + } + } + + @Test + fun givenMediaEntries_whenInspectingSource_thenAllRoutesAndTypedFactoriesArePresent() { + val source = source("navigation/routes/media/MediaNavigation3Entries.kt") + listOf( + "ConversationMediaRoute", + "ImagesPreviewRoute", + "MediaGalleryRoute", + "MessageDetailsRoute", + "LoggedOutImportMediaRoute", + "AuthenticatedImportMediaRoute", + ).forEach { assertTrue(source.contains("wireEntry<$it>")) } + assertFalse(source.contains("ScreenDestination")) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("Bundle")) + assertTrue(source.contains("toViewModelArgs()")) + } + + private fun source(path: String): String { + val root = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/$path").readText() + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Test.kt new file mode 100644 index 00000000000..31f2614afa9 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Test.kt @@ -0,0 +1,50 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ +package com.wire.android.navigation.routes.media + +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MediaNavigation3Test { + private val session = WireSessionId("user", "wire.example") + private val conversation = MediaConversationId("conversation", "wire.example") + + @Test + fun givenImportMediaVariants_whenInspectingScope_thenEachHasOneUnambiguousOwner() { + val loggedOut = LoggedOutImportMediaRoute() + val authenticated = AuthenticatedImportMediaRoute(session) + + assertTrue(loggedOut is AuthenticationRoute) + assertTrue(authenticated is SessionRoute) + assertEquals(loggedOut.routeId, authenticated.routeId) + assertEquals(session, authenticated.sessionId) + } + + @Test + fun givenMediaRoutes_whenSerialized_thenArgumentsAndIdentitySurvive() { + assertRoundTrip(ConversationMediaRoute(session, conversation)) + assertRoundTrip(ImagesPreviewRoute(session, conversation, "name", listOf("content://asset"))) + assertRoundTrip(MediaGalleryRoute(session, conversation, "message", true, false, true)) + assertRoundTrip(MessageDetailsRoute(session, conversation, "message", true)) + assertRoundTrip(LoggedOutImportMediaRoute()) + assertRoundTrip(AuthenticatedImportMediaRoute(session)) + assertRoundTrip( + AuthenticatedImportMediaRoute( + sessionId = session, + internalAssetUriStrings = listOf("content://com.wire.android.share/logs.zip"), + ) + ) + } + + private inline fun assertRoundTrip(value: T) { + assertEquals(value, Json.decodeFromString(Json.encodeToString(value))) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3SourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3SourceTest.kt new file mode 100644 index 00000000000..8ee223bc5ff --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityNavigation3SourceTest.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.utility + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class UtilityNavigation3SourceTest { + + @Test + fun givenUtilityEntries_whenInspectingSource_thenAllEntriesAreTypedAndNavigation2Free() { + val source = sourceFile("UtilityNavigation3Entries.kt").readText() + + assertEquals(5, Regex("""wireEntry<""").findAll(source).count()) + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + ).forEach { registration -> assertTrue(registration in source, registration) } + listOf( + "com.ramcosta", + "NavController", + "com.wire.android.navigation.Navigator", + "com.wire.android.navigation.NavigationCommand", + "Bundle", + "SavedStateHandle", + "DEFAULT_ARGS_KEY", + ).forEach { forbidden -> assertFalse(forbidden in source, forbidden) } + } + + @Test + fun givenUtilityRoutes_whenInspectingSource_thenContractsRemainKmpReady() { + val source = sourceFile("UtilityRoutes.kt").readText() + val imports = source.lineSequence() + .filter { it.startsWith("import ") } + .toList() + + assertEquals(5, Regex("""\) : SessionRoute""").findAll(source).count()) + assertFalse(imports.any { it.startsWith("import android.") }) + assertFalse(imports.any { it.startsWith("import androidx.") }) + assertFalse(imports.any { it.startsWith("import com.ramcosta") }) + assertFalse(imports.any { it.startsWith("import com.wire.kalium") }) + } + + private fun sourceFile(name: String): File { + val relative = "src/main/kotlin/com/wire/android/navigation/routes/utility/$name" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutesTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutesTest.kt new file mode 100644 index 00000000000..880d4ad9ff9 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/utility/UtilityRoutesTest.kt @@ -0,0 +1,65 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.utility + +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class UtilityRoutesTest { + + @Test + fun givenInitialSyncRoute_whenClassifyingScreen_thenItSuppressesAuthenticationChrome() { + assertInstanceOf(AuthenticationScreenRoute::class.java, InitialSyncRoute(SESSION_ID)) + } + + @Test + fun givenUtilityRoutes_whenReadingRouteIds_thenLegacyAnalyticsIdentitiesArePreserved() { + assertEquals("app/initial_sync_screen", InitialSyncRoute.ROUTE_ID) + assertEquals("app/debug_screen", DebugRoute.ROUTE_ID) + assertEquals("app/log_management_screen", LogManagementRoute.ROUTE_ID) + assertEquals("app/debug_feature_flags_screen", DebugFeatureFlagsRoute.ROUTE_ID) + assertEquals("app/conversation_crypto_stats_screen", ConversationCryptoStatsRoute.ROUTE_ID) + } + + @Test + fun givenInitialSyncRoute_whenSerializedAndRestored_thenSessionAndEntryIdentityArePreserved() { + val route = InitialSyncRoute( + sessionId = SESSION_ID, + entryId = WireNavEntryId("initial-sync-entry"), + ) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals(SESSION_ID, restored.sessionId) + assertEquals("initial-sync-entry", restored.entryId.value) + } + + @Test + fun givenSameDebugDestination_whenCreatedTwice_thenEntryIdentitiesAreDifferent() { + val first = DebugRoute(SESSION_ID) + val second = DebugRoute(SESSION_ID) + + assertEquals(first.routeId, second.routeId) + assertNotEquals(first.entryId, second.entryId) + } + + private companion object { + val SESSION_ID = WireSessionId("user", "wire.example") + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/MetroWireEntryGraphResolverTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/MetroWireEntryGraphResolverTest.kt new file mode 100644 index 00000000000..2c7f44a09f7 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/MetroWireEntryGraphResolverTest.kt @@ -0,0 +1,144 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.di.metro.AppAuthenticationViewModelGraph +import com.wire.android.di.metro.AppSessionViewModelGraph +import com.wire.android.di.metro.WireApplicationGraph +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +class MetroWireEntryGraphResolverTest { + + private val appGraph = mockk() + private val authenticationGraph = AppAuthenticationViewModelGraph() + private val sessionGraphStore = mockk() + private val resolver = MetroWireEntryGraphResolver( + appGraph = appGraph, + authenticationGraph = authenticationGraph, + sessionGraphStore = sessionGraphStore, + ) + + @Test + fun givenAuthenticationRoute_whenResolvingGraph_thenAuthenticationGraphAndApplicationFactoryAreUsed() { + val applicationFactory = mockk() + every { appGraph.metroViewModelFactory } returns applicationFactory + + val result = resolver.resolve(TestAuthenticationRoute) + + val entryGraph = (result as MetroWireEntryGraphResolution.Available).entryGraph + assertSame(authenticationGraph, entryGraph.graph) + assertSame(applicationFactory, entryGraph.viewModelFactory) + assertEquals(null, entryGraph.sessionGraph) + } + + @Test + fun givenSessionRoute_whenResolvingGraph_thenMatchingRetainedSessionGraphAndFactoryAreUsed() { + val sessionId = WireSessionId(value = "user", domain = "wire.test") + val userId = UserId(value = "user", domain = "wire.test") + val sessionGraph = mockk() + val sessionFactory = mockk() + every { sessionGraphStore.graphFor(userId) } returns sessionGraph + every { sessionGraph.metroViewModelFactory } returns sessionFactory + + val result = resolver.resolve(TestSessionRoute(sessionId)) + + val entryGraph = (result as MetroWireEntryGraphResolution.Available).entryGraph + assertSame(sessionGraph, entryGraph.graph) + assertSame(sessionFactory, entryGraph.viewModelFactory) + assertSame(sessionGraph, entryGraph.sessionGraph) + } + + @Test + fun givenUnscopedRoute_whenResolvingGraph_thenUnavailableIsReturned() { + val result = resolver.resolve(TestUnscopedRoute) + + assertEquals( + MetroWireEntryGraphResolution.Unavailable( + WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.ROUTE_HAS_NO_SCOPE) + ), + result, + ) + } + + @Test + fun givenRemovedSessionRoute_whenResolvingGraph_thenTypedUnavailableIsReturned() { + val sessionId = WireSessionId(value = "removed", domain = "wire.test") + val userId = UserId(value = "removed", domain = "wire.test") + every { sessionGraphStore.graphFor(userId) } throws + SessionGraphUnavailableException("session:", SessionGraphStoreViewModel.Lifecycle.REMOVED) + + val result = resolver.resolve(TestSessionRoute(sessionId)) + + assertEquals( + MetroWireEntryGraphResolution.Unavailable( + WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.SESSION_GRAPH_REMOVED) + ), + result, + ) + verify(exactly = 0) { sessionGraphStore.lifecycle(userId) } + } + + @Test + fun givenInvalidatingSessionRoute_whenLifecycleChangesAfterFailure_thenOriginalFailureIsMapped() { + val sessionId = WireSessionId(value = "invalidating", domain = "wire.test") + val userId = UserId(value = "invalidating", domain = "wire.test") + every { sessionGraphStore.graphFor(userId) } throws SessionGraphUnavailableException( + "session:", + SessionGraphStoreViewModel.Lifecycle.INVALIDATING, + ) + val result = resolver.resolve(TestSessionRoute(sessionId)) + + assertEquals( + MetroWireEntryGraphResolution.Unavailable( + WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.SESSION_GRAPH_INVALIDATING) + ), + result, + ) + verify(exactly = 0) { sessionGraphStore.lifecycle(userId) } + } + + private data object TestAuthenticationRoute : AuthenticationRoute { + override val entryId: WireNavEntryId = WireNavEntryId("authentication-entry") + override val routeId: String = "authentication" + } + + private data class TestSessionRoute( + override val sessionId: WireSessionId, + ) : SessionRoute { + override val entryId: WireNavEntryId = WireNavEntryId("session-entry") + override val routeId: String = "session" + } + + private data object TestUnscopedRoute : WireRoute { + override val entryId: WireNavEntryId = WireNavEntryId("unscoped-entry") + override val routeId: String = "unscoped" + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3LegacyScreenCleanupSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3LegacyScreenCleanupSourceTest.kt new file mode 100644 index 00000000000..b47f38b9806 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3LegacyScreenCleanupSourceTest.kt @@ -0,0 +1,82 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +class Navigation3LegacyScreenCleanupSourceTest { + + @Test + fun givenNavigation3Runtime_whenInspectingReleaseConfiguration_thenComposeDestinationsArtifactsCannotReturn() { + listOf( + File(projectRoot(), "app/proguard-rules.pro"), + File(projectRoot(), "app/src/main/baseline-prof.txt"), + File(projectRoot(), "app/src/main/startup-prof.txt"), + ).forEach { configurationFile -> + assertFalse( + "com.ramcosta.composedestinations" in configurationFile.readText(), + "${configurationFile.relativeTo(projectRoot())} still references Compose Destinations", + ) + } + } + + @Test + fun givenMigratedScreenScopes_whenInspectingProductionSources_thenLegacyNavigationCannotReturn() { + migratedSources().forEach { sourceFile -> + val source = sourceFile.readText() + listOf( + "import com.ramcosta", + "import com.wire.android.navigation.Navigator", + "import com.wire.android.navigation.NavigationCommand", + "import com.wire.android.navigation.BackStackMode", + "import com.wire.android.navigation.rememberNavigator", + "import com.wire.android.navigation.annotation", + "@WireRootDestination", + "@WireHomeDestination", + "@WirePersonalToTeamMigrationDestination", + ).forEach { forbidden -> + assertFalse( + forbidden in source, + "${sourceFile.relativeTo(projectRoot())} still contains $forbidden", + ) + } + } + } + + private fun migratedSources(): List { + val main = File(projectRoot(), "app/src/main/kotlin/com/wire/android/ui") + val roots = listOf( + File(main, "userprofile"), + File(main, "debug"), + File(main, "initialsync"), + File(main, "sharing"), + File(main, "e2eiEnrollment"), + File(main, "home/vault"), + File(main, "home/archive"), + File(main, "home/conversationslist/all"), + File(main, "home/whatsnew"), + File(main, "home/settings"), + File(main, "settings"), + ) + val files = roots.flatMap { root -> + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + } + return files + listOf( + File(main, "home/cell/GlobalCellsScreen.kt"), + ) + } + + private fun projectRoot(): File = + generateSequence(File(requireNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3RecreationResultOwnershipSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3RecreationResultOwnershipSourceTest.kt new file mode 100644 index 00000000000..991b851db16 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/Navigation3RecreationResultOwnershipSourceTest.kt @@ -0,0 +1,65 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class Navigation3RecreationResultOwnershipSourceTest { + + @Test + fun givenFolderAndDrawingResults_whenInspectingEntries_thenRequestIdsAreSaveableAndLocallyConsumed() { + val home = source("ui/home/HomeNavigation3Entry.kt") + val conversation = source("ui/home/conversations/ConversationNavigation3Entries.kt") + val details = source( + "ui/home/conversations/details/ConversationDetailsNavigation3Entries.kt" + ) + val profile = source("ui/userprofile/UserProfileNavigation3Entries.kt") + + assertSaveableResult(home, "folderRequestIdValue", "ConversationFoldersNavigation3ResultType") + assertSaveableResult(conversation, "drawingRequestId", "DrawingCanvasNavigation3ResultType") + assertSaveableResult(details, "folderRequest", "ConversationFoldersNavigation3ResultType") + assertSaveableResult(profile, "folderRequestIdValue", "ConversationFoldersNavigation3ResultType") + } + + @Test + fun givenProductionActions_whenInspectingSource_thenCallbacksAreNotUsedAsResultPersistence() { + val source = source("navigation/runtime/WireNavigation3ProductionActions.kt") + + assertFalse("PendingFolderResult" in source) + assertFalse("PendingDrawingResult" in source) + assertFalse("pendingFolderResults" in source) + assertFalse("pendingDrawingResults" in source) + assertFalse("folderResultRevision" in source) + assertFalse("drawingResultRevision" in source) + } + + private fun assertSaveableResult( + source: String, + requestIdName: String, + resultTypeName: String, + ) { + assertTrue("rememberSaveable" in source) + assertTrue(requestIdName in source) + assertTrue(resultTypeName in source) + assertTrue("runtime.navigateForResult(" in source) + assertTrue("runtime.consumeResult(" in source) + assertTrue("WireNavResultRequestId" in source) + } + + private fun source(path: String): String { + val root = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/$path").readText() + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModelTest.kt new file mode 100644 index 00000000000..fe4f1008e4d --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModelTest.kt @@ -0,0 +1,435 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.wire.android.di.metro.AppSessionViewModelGraph +import com.wire.android.util.ui.WireSessionImageLoader +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.auth.LogoutCallback +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class SessionGraphStoreViewModelTest { + + @Test + fun givenSameUser_whenResolvingGraphMultipleTimes_thenSameGraphIsReturned() { + val expectedGraph = mockk() + var creationCount = 0 + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + expectedGraph + } + ) + val userId = UserId("user-one", "wire.test") + + val firstGraph = store.graphFor(userId) + val secondGraph = store.graphFor(userId) + + assertSame(expectedGraph, firstGraph) + assertSame(firstGraph, secondGraph) + assertEquals(1, creationCount) + } + + @Test + fun givenDifferentUsers_whenResolvingGraphs_thenEachUserHasItsOwnGraph() { + val firstGraph = mockk(relaxed = true) + val secondGraph = mockk(relaxed = true) + val graphs = listOf(firstGraph, secondGraph).iterator() + val store = SessionGraphStoreViewModel(createSessionGraph = { graphs.next() }) + + val resolvedFirstGraph = store.graphFor(UserId("user-one", "wire.test")) + val resolvedSecondGraph = store.graphFor(UserId("user-two", "wire.test")) + + assertSame(firstGraph, resolvedFirstGraph) + assertSame(secondGraph, resolvedSecondGraph) + } + + @Test + fun givenActiveCallGraph_whenReleased_thenItIsDisposedWithoutSessionTombstone() { + val userId = UserId("user-one", "wire.test") + val firstLoader = mockk(relaxed = true) + val secondLoader = mockk(relaxed = true) + val firstGraph = mockk(relaxed = true) { + every { wireSessionImageLoader } returns firstLoader + } + val secondGraph = mockk(relaxed = true) { + every { wireSessionImageLoader } returns secondLoader + } + val graphs = listOf(firstGraph, secondGraph).iterator() + val store = SessionGraphStoreViewModel(createSessionGraph = { graphs.next() }) + + assertSame(firstGraph, store.graphFor(userId)) + store.release(userId) + + assertEquals(null, store.lifecycle(userId)) + verify(exactly = 1) { firstLoader.shutdown() } + assertSame(secondGraph, store.graphFor(userId)) + verify(exactly = 0) { secondLoader.shutdown() } + } + + @Test + fun givenInvalidatingGraph_whenReleased_thenLogoutTombstoneIsPreserved() { + val userId = UserId("user-one", "wire.test") + val loader = mockk(relaxed = true) + val graph = mockk(relaxed = true) { + every { wireSessionImageLoader } returns loader + } + val store = SessionGraphStoreViewModel(createSessionGraph = { graph }) + store.graphFor(userId) + store.markInvalidating(userId) + + store.release(userId) + + assertEquals(SessionGraphStoreViewModel.Lifecycle.REMOVED, store.lifecycle(userId)) + assertThrows(SessionGraphUnavailableException::class.java) { store.graphFor(userId) } + verify(exactly = 1) { loader.shutdown() } + } + + @Test + fun givenSessionIsLoggedOut_whenStaleRouteResolvesSameUser_thenGraphIsNotRecreated() = runTest { + val userId = UserId("user-one", "wire.test") + val firstGraph = mockk(relaxed = true) + var creationCount = 0 + var logoutCallback: LogoutCallback? = null + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + firstGraph + }, + registerLogoutCallback = { logoutCallback = it }, + ) + + val resolvedBeforeLogout = store.graphFor(userId) + requireNotNull(logoutCallback).invoke(userId, LogoutReason.SELF_HARD_LOGOUT) + + assertSame(firstGraph, resolvedBeforeLogout) + assertEquals(SessionGraphStoreViewModel.Lifecycle.INVALIDATING, store.lifecycle(userId)) + assertThrows(SessionGraphUnavailableException::class.java) { store.graphFor(userId) } + assertEquals(1, creationCount) + } + + @Test + fun givenSessionIsInvalidating_whenStaleRouteResolvesSameUser_thenGraphIsNotRecreated() { + val userId = UserId("user-one", "wire.test") + val graph = mockk(relaxed = true) + val imageLoader = mockk(relaxed = true) + every { graph.wireSessionImageLoader } returns imageLoader + var creationCount = 0 + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + graph + } + ) + store.graphFor(userId) + + store.markInvalidating(userId) + + assertEquals(SessionGraphStoreViewModel.Lifecycle.INVALIDATING, store.lifecycle(userId)) + assertThrows(SessionGraphUnavailableException::class.java) { store.graphFor(userId) } + assertEquals(1, creationCount) + verify(exactly = 0) { imageLoader.shutdown() } + + store.markRemoved(userId) + + verify(exactly = 1) { imageLoader.shutdown() } + } + + @Test + fun givenRemovedSessionIsConfirmedAvailable_whenResolvingAgain_thenFreshGraphIsCreated() { + val userId = UserId("user-one", "wire.test") + val firstGraph = mockk(relaxed = true) + val secondGraph = mockk(relaxed = true) + val graphs = listOf(firstGraph, secondGraph).iterator() + val store = SessionGraphStoreViewModel(createSessionGraph = { graphs.next() }) + val resolvedBeforeRemoval = store.graphFor(userId) + + store.markRemoved(userId) + store.markAvailable(userId) + val resolvedAfterReactivation = store.graphFor(userId) + + assertSame(firstGraph, resolvedBeforeRemoval) + assertSame(secondGraph, resolvedAfterReactivation) + assertNotSame(resolvedBeforeRemoval, resolvedAfterReactivation) + assertEquals(SessionGraphStoreViewModel.Lifecycle.ACTIVE, store.lifecycle(userId)) + } + + @Test + fun givenInvalidatingSession_whenMarkedAvailableBeforeRemoval_thenOldGenerationCannotReopen() { + val userId = UserId("user-one", "wire.test") + val graph = mockk(relaxed = true) + val store = SessionGraphStoreViewModel(createSessionGraph = { graph }) + store.graphFor(userId) + store.markInvalidating(userId) + + assertThrows(IllegalStateException::class.java) { store.markAvailable(userId) } + assertEquals(SessionGraphStoreViewModel.Lifecycle.INVALIDATING, store.lifecycle(userId)) + assertThrows(SessionGraphUnavailableException::class.java) { store.graphFor(userId) } + } + + @Test + fun givenGraphCreationInProgress_whenLogoutInvalidatesSession_thenCreatedGraphIsDiscarded() { + val userId = UserId("user-one", "wire.test") + val creationStarted = CountDownLatch(1) + val allowCreationToFinish = CountDownLatch(1) + val loader = mockk(relaxed = true) + val graph = mockk(relaxed = true) { + every { wireSessionImageLoader } returns loader + } + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationStarted.countDown() + check(allowCreationToFinish.await(5, TimeUnit.SECONDS)) + graph + } + ) + val executor = Executors.newSingleThreadExecutor() + + try { + val resolution = executor.submit { store.graphFor(userId) } + check(creationStarted.await(5, TimeUnit.SECONDS)) + + store.markInvalidating(userId) + allowCreationToFinish.countDown() + + assertThrows(Exception::class.java) { resolution.get(5, TimeUnit.SECONDS) } + assertEquals(SessionGraphStoreViewModel.Lifecycle.INVALIDATING, store.lifecycle(userId)) + verify(exactly = 1) { loader.shutdown() } + } finally { + allowCreationToFinish.countDown() + executor.shutdownNow() + } + } + + @Test + fun givenCreatingGraphIsReleased_whenLogoutThenInvalidates_thenTombstoneIsPreserved() { + val userId = UserId("user-one", "wire.test") + val creationStarted = CountDownLatch(1) + val allowCreationToFinish = CountDownLatch(1) + val loader = mockk(relaxed = true) + val graph = mockk(relaxed = true) { + every { wireSessionImageLoader } returns loader + } + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationStarted.countDown() + check(allowCreationToFinish.await(5, TimeUnit.SECONDS)) + graph + } + ) + val executor = Executors.newSingleThreadExecutor() + + try { + val resolution = executor.submit { store.graphFor(userId) } + check(creationStarted.await(5, TimeUnit.SECONDS)) + + store.release(userId) + store.markInvalidating(userId) + allowCreationToFinish.countDown() + + assertThrows(Exception::class.java) { resolution.get(5, TimeUnit.SECONDS) } + assertEquals(SessionGraphStoreViewModel.Lifecycle.INVALIDATING, store.lifecycle(userId)) + assertThrows(SessionGraphUnavailableException::class.java) { store.graphFor(userId) } + verify(exactly = 1) { loader.shutdown() } + } finally { + allowCreationToFinish.countDown() + executor.shutdownNow() + } + } + + @Test + fun givenConcurrentResolutionForSameSession_whenGraphIsCreated_thenBothCallersShareOneGraph() { + val userId = UserId("user-one", "wire.test") + val creationStarted = CountDownLatch(1) + val allowCreationToFinish = CountDownLatch(1) + val graph = mockk(relaxed = true) + var creationCount = 0 + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + creationStarted.countDown() + check(allowCreationToFinish.await(5, TimeUnit.SECONDS)) + graph + } + ) + val executor = Executors.newFixedThreadPool(2) + + try { + val first = executor.submit { store.graphFor(userId) } + check(creationStarted.await(5, TimeUnit.SECONDS)) + val second = executor.submit { store.graphFor(userId) } + allowCreationToFinish.countDown() + + assertSame(graph, first.get(5, TimeUnit.SECONDS)) + assertSame(graph, second.get(5, TimeUnit.SECONDS)) + assertEquals(1, creationCount) + } finally { + allowCreationToFinish.countDown() + executor.shutdownNow() + } + } + + @Test + fun givenGraphFactoryFails_whenResolvingAgain_thenCreationCanRetry() { + val userId = UserId("user-one", "wire.test") + val graph = mockk(relaxed = true) + var creationCount = 0 + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + if (creationCount == 1) error("creation failed") else graph + } + ) + + assertThrows(IllegalStateException::class.java) { store.graphFor(userId) } + + assertSame(graph, store.graphFor(userId)) + assertEquals(2, creationCount) + assertEquals(SessionGraphStoreViewModel.Lifecycle.ACTIVE, store.lifecycle(userId)) + } + + @Test + fun givenSessionRemovedBeforeFirstResolution_whenStaleRouteResolves_thenGraphIsNotCreated() { + val userId = UserId("user-one", "wire.test") + var creationCount = 0 + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + mockk() + } + ) + + store.markRemoved(userId) + + assertThrows(SessionGraphUnavailableException::class.java) { store.graphFor(userId) } + assertEquals(0, creationCount) + } + + @Test + fun givenDifferentSessionIsLoggedOut_whenResolvingExistingUser_thenGraphIsRetained() = runTest { + val retainedUserId = UserId("user-one", "wire.test") + val loggedOutUserId = UserId("user-two", "wire.test") + val expectedGraph = mockk() + var logoutCallback: LogoutCallback? = null + var creationCount = 0 + val store = SessionGraphStoreViewModel( + createSessionGraph = { + creationCount++ + expectedGraph + }, + registerLogoutCallback = { logoutCallback = it }, + ) + + val resolvedBeforeLogout = store.graphFor(retainedUserId) + requireNotNull(logoutCallback).invoke(loggedOutUserId, LogoutReason.SELF_HARD_LOGOUT) + val resolvedAfterLogout = store.graphFor(retainedUserId) + + assertSame(resolvedBeforeLogout, resolvedAfterLogout) + assertEquals(1, creationCount) + } + + @Test + fun givenStoreOwnerIsCleared_whenLogoutCallbackWasRegistered_thenCallbackIsUnregistered() { + var registeredCallback: LogoutCallback? = null + var unregisteredCallback: LogoutCallback? = null + val factory = viewModelFactory { + initializer { + SessionGraphStoreViewModel( + createSessionGraph = { mockk() }, + registerLogoutCallback = { registeredCallback = it }, + unregisterLogoutCallback = { unregisteredCallback = it }, + ) + } + } + val viewModelStore = ViewModelStore() + + ViewModelProvider.create(viewModelStore, factory)[SessionGraphStoreViewModel::class] + viewModelStore.clear() + + assertSame(registeredCallback, unregisteredCallback) + } + + @Test + fun givenStoreOwnerIsCleared_whenResolvingAgain_thenSessionGraphCacheIsRecreated() { + val userId = UserId("user-one", "wire.test") + val firstGraph = mockk(relaxed = true) + val secondGraph = mockk(relaxed = true) + val graphs = listOf(firstGraph, secondGraph).iterator() + val factory = viewModelFactory { + initializer { + SessionGraphStoreViewModel(createSessionGraph = { graphs.next() }) + } + } + val viewModelStore = ViewModelStore() + val firstStore = ViewModelProvider.create(viewModelStore, factory)[SessionGraphStoreViewModel::class] + + val resolvedFirstGraph = firstStore.graphFor(userId) + viewModelStore.clear() + val secondStore = ViewModelProvider.create(viewModelStore, factory)[SessionGraphStoreViewModel::class] + val resolvedSecondGraph = secondStore.graphFor(userId) + + assertSame(firstGraph, resolvedFirstGraph) + assertSame(secondGraph, resolvedSecondGraph) + } + + @Test + fun givenSeveralGraphs_whenStoreIsClearedAndOneShutdownFails_thenEveryGraphIsDisposedOnce() { + val firstUser = UserId("user-one", "wire.test") + val secondUser = UserId("user-two", "wire.test") + val firstLoader = mockk { + every { shutdown() } throws IllegalStateException("shutdown failed") + } + val secondLoader = mockk(relaxed = true) + val firstGraph = mockk { + every { wireSessionImageLoader } returns firstLoader + } + val secondGraph = mockk { + every { wireSessionImageLoader } returns secondLoader + } + val graphs = listOf(firstGraph, secondGraph).iterator() + val factory = viewModelFactory { + initializer { SessionGraphStoreViewModel(createSessionGraph = { graphs.next() }) } + } + val viewModelStore = ViewModelStore() + val store = ViewModelProvider.create(viewModelStore, factory)[SessionGraphStoreViewModel::class] + store.graphFor(firstUser) + store.graphFor(secondUser) + + viewModelStore.clear() + + verify(exactly = 1) { firstLoader.shutdown() } + verify(exactly = 1) { secondLoader.shutdown() } + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityDialogsSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityDialogsSourceTest.kt new file mode 100644 index 00000000000..18c448b4e88 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityDialogsSourceTest.kt @@ -0,0 +1,78 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityDialogsSourceTest { + + @Test + fun givenExtractedDialogLayer_whenInspectingImports_thenItIsNavigationNeutral() { + val source = uiSource("WireActivityDialogs.kt").readText() + val imports = source.lineSequence() + .filter { it.startsWith("import ") } + .toList() + + listOf( + "Navigator", + "NavigationCommand", + "NavController", + "com.ramcosta.composedestinations.generated", + "ScreenDestination", + ).forEach { forbidden -> + assertFalse( + imports.any { it.contains(forbidden) }, + "Dialog layer imports legacy navigation API: $forbidden", + ) + } + + assertTrue(source.contains("internal data class WireActivityDialogActions")) + listOf( + "openLoginIfEmptyWelcomeStart", + "openSelfProfile", + "openSelfDevices", + "switchAccountAndOpenSelfDevices", + "openE2EICertificateDetails", + "openJoinedConversation", + "startTeamAppLock", + "hardLogout", + ).forEach { callback -> + assertTrue(source.contains("val $callback"), "Missing semantic callback $callback") + } + } + + @Test + fun givenWireActivityNavigation3Host_whenInspectingSource_thenDialogRenderingIsSemantic() { + val activity = uiSource("WireActivity.kt").readText() + val host = uiSource("WireActivityNavigation3Host.kt").readText() + val actions = uiSource("WireActivityNavigation3DialogActions.kt").readText() + + assertFalse(activity.contains("private fun HandleDialogs(")) + assertFalse(activity.contains("fun HandleDialogs(")) + assertFalse(activity.contains("legacyDialogActions(")) + assertFalse(host.contains("legacyDialogActions(")) + assertTrue(host.contains("WireActivityDialogs(")) + assertTrue(host.contains("navigation3DialogActions(")) + assertTrue(actions.contains("internal fun navigation3DialogActions(")) + assertTrue(actions.contains("internal data class WireActivityDialogActionDependencies")) + } + + private fun uiSource(name: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(projectDir, "app/src/main/kotlin/com/wire/android/ui/$name").also { + assertTrue(it.isFile, "Missing source file $name") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinatorTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinatorTest.kt new file mode 100644 index 00000000000..a0edbfc1909 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentCoordinatorTest.kt @@ -0,0 +1,186 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import android.app.Application +import android.content.Intent +import android.os.Bundle +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35]) +class WireActivityIntentCoordinatorTest { + + private val coordinator = WireActivityIntentCoordinator() + + @Test + fun givenIntentsArriveBeforeCollection_whenCollectingRequests_thenArrivalOrderIsPreserved() = runTest { + val initial = Intent("initial") + val firstNew = Intent("first-new") + val secondNew = Intent("second-new") + coordinator.enqueue(initial, Bundle()) + coordinator.enqueue(firstNew) + coordinator.enqueue(secondNew) + + val requests = coordinator.requests.take(3).toList() + + assertSame(initial, requests[0].intent) + assertSame(firstNew, requests[1].intent) + assertSame(secondNew, requests[2].intent) + } + + @Test + fun givenActivityStateIsSaved_whenRestoring_thenOriginalIntentIsReturned() { + val original = Intent(Intent.ACTION_VIEW) + val state = Bundle() + + coordinator.saveInstanceState(state, original) + + assertSame(original, coordinator.restoreActivityIntent(state)) + assertTrue(state.getBoolean("deeplink_handled_flag_key")) + } + + @Test + fun givenStateWrittenByLegacyActivity_whenRestoring_thenOriginalIntentIsReturned() { + val original = Intent(Intent.ACTION_VIEW) + val legacyState = Bundle().apply { + putParcelable("original_saved_intent", original) + } + + assertSame(original, coordinator.restoreActivityIntent(legacyState)) + } + + @Test + fun givenEligibleDeepLink_whenNotHandledAsAnotherIntent_thenDeepLinkIsHandledOnce() = runTest { + val intent = Intent(Intent.ACTION_VIEW) + var deepLinkCalls = 0 + + val firstEffect = coordinator.handle( + request = WireActivityIntentRequest(intent, null), + isEmptyWelcomeStartDestination = { false }, + handleNonDeepLinkIntent = { false }, + handleDeepLink = { deepLinkCalls++ }, + ) + val secondEffect = coordinator.handle( + request = WireActivityIntentRequest(intent, null), + isEmptyWelcomeStartDestination = { false }, + handleNonDeepLinkIntent = { false }, + handleDeepLink = { deepLinkCalls++ }, + ) + + assertEquals(WireActivityIntentEffect.NONE, firstEffect) + assertEquals(WireActivityIntentEffect.NONE, secondEffect) + assertEquals(1, deepLinkCalls) + } + + @Test + fun givenEligibleIntentHandledAsNonDeepLink_whenHandling_thenDeepLinkIsNotInvoked() = runTest { + var deepLinkHandled = false + + coordinator.handle( + request = WireActivityIntentRequest(Intent(Intent.ACTION_VIEW), null), + isEmptyWelcomeStartDestination = { false }, + handleNonDeepLinkIntent = { true }, + handleDeepLink = { deepLinkHandled = true }, + ) + + assertFalse(deepLinkHandled) + } + + @Test + fun givenRestoredOriginalIntent_whenNothingElseHandlesIt_thenLoginFallbackIsRequested() = runTest { + val original = Intent(Intent.ACTION_VIEW) + val savedState = Bundle().also { coordinator.saveInstanceState(it, original) } + var deepLinkHandled = false + + val effect = coordinator.handle( + request = WireActivityIntentRequest(original, savedState), + isEmptyWelcomeStartDestination = { true }, + handleNonDeepLinkIntent = { false }, + handleDeepLink = { deepLinkHandled = true }, + ) + + assertEquals(WireActivityIntentEffect.OPEN_LOGIN, effect) + assertFalse(deepLinkHandled) + } + + @Test + fun givenDeepLinkEligibleIntent_whenHandling_thenWelcomeFallbackIsNotInspected() = runTest { + var welcomeWasInspected = false + + coordinator.handle( + request = WireActivityIntentRequest(Intent(Intent.ACTION_VIEW), null), + isEmptyWelcomeStartDestination = { + welcomeWasInspected = true + true + }, + handleNonDeepLinkIntent = { false }, + handleDeepLink = {}, + ) + + assertFalse(welcomeWasInspected) + } + + @Test + fun givenLauncherIntentHandledAsNonDeepLink_whenHandling_thenWelcomeFallbackIsNotRequested() = runTest { + var deepLinkHandled = false + + val effect = coordinator.handle( + request = WireActivityIntentRequest(Intent(Intent.ACTION_MAIN), null), + isEmptyWelcomeStartDestination = { true }, + handleNonDeepLinkIntent = { true }, + handleDeepLink = { deepLinkHandled = true }, + ) + + assertEquals(WireActivityIntentEffect.NONE, effect) + assertFalse(deepLinkHandled) + } + + @Test + fun givenIntentFromHistory_whenHandling_thenOnlyNonDeepLinkPathRuns() = runTest { + val intent = Intent(Intent.ACTION_VIEW).apply { + flags = Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY + } + var nonDeepLinkHandled = false + var deepLinkHandled = false + + coordinator.handle( + request = WireActivityIntentRequest(intent, null), + isEmptyWelcomeStartDestination = { false }, + handleNonDeepLinkIntent = { + nonDeepLinkHandled = true + false + }, + handleDeepLink = { deepLinkHandled = true }, + ) + + assertTrue(nonDeepLinkHandled) + assertFalse(deepLinkHandled) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentDecisionTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentDecisionTest.kt new file mode 100644 index 00000000000..943e8eedfef --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityIntentDecisionTest.kt @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WireActivityIntentDecisionTest { + + @Test + fun givenEligibleIntent_whenDecidingHandling_thenNonDeepLinkAndDeepLinkHandlersAreAllowed() { + val result = decideWireActivityIntentHandling(WireActivityIntentEvidence()) + + assertEquals(WireActivityIntentHandling.NON_DEEP_LINK_THEN_DEEP_LINK, result) + } + + @Test + fun givenAnyDeepLinkExclusion_whenDecidingHandling_thenOnlyNonDeepLinkHandlerIsAllowed() { + val exclusions = listOf( + WireActivityIntentEvidence(isMissing = true), + WireActivityIntentEvidence(isLauncherIntent = true), + WireActivityIntentEvidence(isLaunchedFromHistory = true), + WireActivityIntentEvidence(isRestoredOriginalIntent = true), + WireActivityIntentEvidence(wasDeepLinkHandled = true), + ) + + exclusions.forEach { evidence -> + assertEquals( + WireActivityIntentHandling.NON_DEEP_LINK_ONLY, + decideWireActivityIntentHandling(evidence), + "Expected exclusion for $evidence", + ) + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityNavigation3GraphContextSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityNavigation3GraphContextSourceTest.kt new file mode 100644 index 00000000000..9ee02d97699 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityNavigation3GraphContextSourceTest.kt @@ -0,0 +1,44 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityNavigation3GraphContextSourceTest { + + @Test + fun givenNavigation3GraphContext_whenInspectingSource_thenItUsesTypedResolverOnly() { + val source = sourceFile().readText() + val function = source.substringAfter("fun rememberWireNavigation3ActivityGraphContext") + .substringBefore("@Composable\ninternal fun rememberWireActivityGraphContext") + + assertTrue("graphResolver.resolve(route)" in function) + listOf( + "Bundle", + "NavController", + "com.ramcosta", + "LegacyWireActivityRouteClassifier", + "currentBackStackEntry", + ).forEach { forbidden -> assertFalse(forbidden in function, forbidden) } + } + + private fun sourceFile(): File { + val relative = "src/main/kotlin/com/wire/android/navigation/runtime/WireActivityGraphContext.kt" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityProductionNavigation3HostSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityProductionNavigation3HostSourceTest.kt new file mode 100644 index 00000000000..f77bffa9fee --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityProductionNavigation3HostSourceTest.kt @@ -0,0 +1,93 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityProductionNavigation3HostSourceTest { + + @Test + fun givenProductionWireActivity_whenInspectingSource_thenNavigation3OwnsTheRuntimeAndHost() { + val activity = wireActivitySource() + val source = navigation3HostSource() + + assertTrue( + "WireActivityNavigation3Host(" in activity, + "WireActivity must delegate Compose orchestration to its Navigation 3 host", + ) + + listOf( + "rememberWireNavigation3Runtime(", + "rememberWireNavigation3ActivityGraphContext(", + "MetroWireEntryGraphResolver(", + "WireActivityGraphContext", + "rememberWireNavigation3ProductionActions(", + "WireNavigation3ProductionHost(", + ).forEach { required -> + assertTrue(required in source, "WireActivity is missing Navigation 3 host element: $required") + } + } + + @Test + fun givenProductionWireActivity_whenInspectingSource_thenNavigation3OwnsSetupAndEffects() { + val source = navigation3HostSource() + + listOf( + "WireNavigation3ActivityEffects(", + "WireNavigation3ActivityCallbacks(", + "HandleNavigation3SessionEffects(", + "WireActivityNavigation3Setup(", + "HandleNavigation3ViewActions(", + ).forEach { required -> + assertTrue(required in source, "WireActivity is missing Navigation 3 effect handling: $required") + } + } + + @Test + fun givenProductionWireActivity_whenInspectingSource_thenLegacyHostCannotReturn() { + val source = wireActivitySource() + navigation3HostSource() + + listOf( + "MainNavHost", + "rememberNavigator", + "NavController", + "com.ramcosta.composedestinations", + ".generated.", + "ScreenDestination", + "LegacyStartupDirectionMapper", + "toLegacyDirection", + "LegacyWireActivityRouteClassifier", + "rememberWireActivityGraphContext(", + "resolveWireActivityNavHostStartDestination", + ).forEach { forbidden -> + assertFalse(forbidden in source, "WireActivity still references legacy navigation: $forbidden") + } + } + + private fun wireActivitySource(): String { + return uiSource("WireActivity.kt") + } + + private fun navigation3HostSource(): String = + uiSource("WireActivityNavigation3Host.kt") + + private fun uiSource(name: String): String { + val relative = "src/main/kotlin/com/wire/android/ui/$name" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile).readText() + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityStructureSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityStructureSourceTest.kt new file mode 100644 index 00000000000..fed29fe92ea --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityStructureSourceTest.kt @@ -0,0 +1,64 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityStructureSourceTest { + + @Test + fun givenWireActivity_whenInspectingStructure_thenComposeAndDialogOrchestrationStayExtracted() { + val activity = uiSource("WireActivity.kt") + val host = uiSource("WireActivityNavigation3Host.kt") + val dialogActions = uiSource("WireActivityNavigation3DialogActions.kt") + + assertTrue(activity.lineSequence().count() < 500, "WireActivity became an orchestration container again") + assertTrue("WireActivityNavigation3Host(" in activity) + assertFalse("@Composable" in activity, "WireActivity must not own composable host functions") + assertFalse("WireActivityDialogs(" in activity, "WireActivity must not assemble global dialogs") + assertTrue("WireNavigation3ProductionHost(" in host) + assertTrue("navigation3DialogActions(" in host) + assertTrue("WireActivityDialogActionDependencies(" in dialogActions) + } + + @Test + fun givenExtractedActivityHost_whenInspectingSources_thenLegacyHostApisCannotReturn() { + val sources = listOf( + uiSource("WireActivity.kt"), + uiSource("WireActivityNavigation3Host.kt"), + uiSource("WireActivityNavigation3DialogActions.kt"), + ) + listOf( + "MainNavHost", + "DestinationsNavHost", + "rememberNavController", + "NavController", + "com.ramcosta", + ".generated.", + "ScreenDestination", + "com.wire.android.navigation.NavigationCommand", + ).forEach { forbidden -> + assertFalse( + sources.any { forbidden in it }, + "WireActivity host layer contains legacy API: $forbidden", + ) + } + } + + private fun uiSource(name: String): String { + val root = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/ui/$name").readText() + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityViewModelOwnershipSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityViewModelOwnershipSourceTest.kt new file mode 100644 index 00000000000..24b4f2fbbfd --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireActivityViewModelOwnershipSourceTest.kt @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityViewModelOwnershipSourceTest { + + @Test + fun givenActivityChromeViewModels_whenInspectingWireActivity_thenOwnershipIsDelegated() { + val activity = sourceFile("WireActivity.kt").readText() + val host = sourceFile("WireActivityNavigation3Host.kt").readText() + val owner = sourceFile("WireActivityScopedViewModels.kt").readText() + + assertTrue(host.contains("wireActivityScopedViewModels(activitySessionViewModelStoreOwner)")) + assertFalse(activity.contains("CallFeedbackViewModel:")) + assertFalse(activity.contains("CommonTopAppBarViewModel:")) + assertFalse(activity.contains(".callingViewModelFactory.callFeedbackViewModel()")) + assertFalse(activity.contains(".miscViewModelFactory.legalHoldRequestedViewModel()")) + assertFalse(host.contains(".callingViewModelFactory.callFeedbackViewModel()")) + assertFalse(host.contains(".miscViewModelFactory.legalHoldRequestedViewModel()")) + + assertTrue(owner.contains("wireMetroViewModel(owner = owner)")) + assertFalse(owner.contains("sessionKeyedMetroViewModel")) + assertTrue(owner.contains("commonTopAppBarViewModel(")) + assertTrue(host.contains("teardownWireActivitySession(")) + assertTrue(host.contains("sessionGraphStore.markAvailable(confirmedUserId)")) + assertFalse(host.contains("sessionGraphStore::invalidate")) + assertFalse(host.contains("invalidateActive()")) + assertFalse(host.contains("retainedGraphContext")) + assertFalse(host.contains("sessionGraph?.currentAccount")) + assertFalse(host.contains("imageLoaderSessionGraph")) + assertTrue(host.contains("retainedRouteSessionId")) + assertFalse(activity.contains("LastKnownCurrentAccount")) + assertFalse(host.contains("LastKnownCurrentAccount")) + assertFalse(activity.contains("WireNavigationCommand(NewLoginRoute.start()")) + assertTrue(activity.contains("authenticationRouter.openLoginFromActivity()")) + } + + private fun sourceFile(name: String): File { + val relative = "src/main/kotlin/com/wire/android/ui/$name" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsAndroidTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsAndroidTest.kt new file mode 100644 index 00000000000..cc2bc64c598 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsAndroidTest.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import android.app.Application +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.wire.android.navigation.routes.auth.AuthenticationLoginArguments +import com.wire.android.navigation.routes.auth.AuthenticationTeamAccountCreationRequest +import com.wire.android.navigation.routes.auth.NewLoginPasswordRoute +import com.wire.android.ui.WireActivity +import com.wire.navigation.WireBackStackMode +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35]) +class WireNavigation3ActivityEffectsAndroidTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun givenFeedbackIsRequested_whenBuildingIntent_thenLegacyEmailChooserPayloadIsPreserved() { + val chooser = buildWireNavigation3FeedbackIntent(context) + val emailIntent = checkNotNull( + chooser.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + ) + + assertEquals(Intent.ACTION_CHOOSER, chooser.action) + assertEquals(Intent.ACTION_SEND, emailIntent.action) + assertArrayEquals( + arrayOf("wire-newandroid-feedback@wearezeta.zendesk.com"), + emailIntent.getStringArrayExtra(Intent.EXTRA_EMAIL), + ) + assertEquals("Feedback - Wire Beta", emailIntent.getStringExtra(Intent.EXTRA_SUBJECT)) + assertEquals(Intent.ACTION_SENDTO, emailIntent.selector?.action) + assertEquals("mailto:", emailIntent.selector?.dataString) + assertTrue(emailIntent.getStringExtra(Intent.EXTRA_TEXT).orEmpty().isNotBlank()) + } + + @Test + fun givenTeamAccountWebRequest_whenBuildingLaunch_thenUrlAndUpdateExistingReturnArePreserved() { + val returnRoute = NewLoginPasswordRoute( + args = AuthenticationLoginArguments(), + flowId = "create-account-flow", + ) + val launch = buildWireNavigation3TeamAccountWebLaunch( + context, + AuthenticationTeamAccountCreationRequest( + url = "https://wire.test/register", + returnRoute = returnRoute, + ), + ) + + assertEquals("https://wire.test/register", launch.intent.dataString) + assertSame(returnRoute, launch.returnRoute) + assertEquals(returnRoute, launch.returnCommand().destination) + assertEquals(WireBackStackMode.UPDATE_EXISTING, launch.returnCommand().backStackMode) + } + + @Test + fun givenLogoutRestart_whenBuildingIntent_thenWireActivityReplacesTheTask() { + val intent = buildWireNavigation3RestartIntent(context) + + assertEquals(WireActivity::class.java.name, intent.component?.className) + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK != 0) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsTest.kt new file mode 100644 index 00000000000..e5b24012e1c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffectsTest.kt @@ -0,0 +1,110 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireNavigation3ActivityEffectsTest { + + private val links = WireNavigation3ExternalLinks( + support = "https://wire.test/support", + teamManagement = "https://wire.test/teams", + teamPlan = "https://wire.test/plans", + termsOfUse = "https://wire.test/legal", + wireWebsite = "https://wire.test", + privacyPolicy = "https://wire.test/privacy", + reportMisuse = "https://wire.test/report", + welcomeAndroid = "https://wire.test/welcome-android", + androidReleaseNotes = "https://wire.test/android-releases", + ) + + @Test + fun givenUrlExternalDestinations_whenResolvingEffects_thenEverySemanticTargetUsesItsOwnUrl() { + val expected = mapOf( + WireNavigation3ExternalIntent.SUPPORT to links.support, + WireNavigation3ExternalIntent.TEAM_MANAGEMENT to links.teamManagement, + WireNavigation3ExternalIntent.TEAM_PLAN to links.teamPlan, + WireNavigation3ExternalIntent.TERMS_OF_USE to links.termsOfUse, + WireNavigation3ExternalIntent.WIRE_WEBSITE to links.wireWebsite, + WireNavigation3ExternalIntent.PRIVACY_POLICY to links.privacyPolicy, + WireNavigation3ExternalIntent.REPORT_MISUSE to links.reportMisuse, + WireNavigation3ExternalIntent.WELCOME_ANDROID to links.welcomeAndroid, + WireNavigation3ExternalIntent.ANDROID_RELEASE_NOTES to links.androidReleaseNotes, + ) + + expected.forEach { (destination, url) -> + assertEquals( + WireNavigation3PlatformEffect.CustomTab(url), + resolveWireNavigation3PlatformEffect(destination, links), + destination.name, + ) + } + } + + @Test + fun givenFeedbackDestination_whenResolvingEffect_thenEmailComposerIsSelected() { + assertSame( + WireNavigation3PlatformEffect.GiveFeedbackEmail, + resolveWireNavigation3PlatformEffect( + WireNavigation3ExternalIntent.GIVE_FEEDBACK, + links, + ), + ) + } + + @Test + fun givenBlankTeamManagementUrl_whenResolvingEffect_thenNoPlatformActionIsProduced() { + assertSame( + WireNavigation3PlatformEffect.None, + resolveWireNavigation3PlatformEffect( + WireNavigation3ExternalIntent.TEAM_MANAGEMENT, + links.copy(teamManagement = ""), + ), + ) + } + + @Test + fun givenActivityEffects_whenInspectingSource_thenNoLegacyNavigationTypeLeaksIntoTheHelper() { + val source = sourceFile().readText() + + listOf( + "WireNavigation3ActivityCallbacks", + "WireNavigation3ExternalIntent", + "CustomTabsHelper", + "SupportUrlResolver", + "WireBackStackMode.UPDATE_EXISTING", + "Intent.FLAG_ACTIVITY_CLEAR_TASK", + "Intent.ACTION_SENDTO", + ).forEach { expected -> assertTrue(expected in source, expected) } + listOf( + "com.ramcosta", + "Direction", + "NavController", + "com.wire.android.navigation.NavigationCommand", + "generated.", + ).forEach { forbidden -> assertFalse(forbidden in source, forbidden) } + } + + private fun sourceFile(): File { + val relative = + "src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityEffects.kt" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt new file mode 100644 index 00000000000..9082229073a --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt @@ -0,0 +1,92 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.navigation.style.BackgroundType +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireNavigation3ActivityPolicyTest { + + @Test + fun givenAuthenticationRoute_whenResolvingActivityPolicy_thenAuthBackgroundAndFlagsAreUsed() { + val snapshot = WireNavigation3ActivityPolicy.sessionSnapshot( + route = WelcomeRoute(), + hasCurrentSession = false, + isUserUiBlocked = false, + isSessionTransitionInProgress = false, + isSelfLogoutTransition = false, + ) + + assertEquals(BackgroundType.Auth, WireNavigation3ActivityPolicy.backgroundType(WelcomeRoute())) + assertTrue(snapshot.isAuthenticationRoute) + assertFalse(snapshot.isSessionBackedAuthenticationRoute) + } + + @Test + fun givenSessionBackedAuthenticationRoute_whenResolvingPolicy_thenBothScopesArePreserved() { + val route = TestSessionAuthenticationRoute + + val snapshot = WireNavigation3ActivityPolicy.sessionSnapshot( + route = route, + hasCurrentSession = false, + isUserUiBlocked = true, + isSessionTransitionInProgress = true, + isSelfLogoutTransition = true, + ) + + assertEquals(BackgroundType.Auth, WireNavigation3ActivityPolicy.backgroundType(route)) + assertTrue(snapshot.isAuthenticationRoute) + assertTrue(snapshot.isSessionBackedAuthenticationRoute) + assertTrue(snapshot.isUserUiBlocked) + assertTrue(snapshot.isSessionTransitionInProgress) + assertTrue(snapshot.isSelfLogoutTransition) + } + + @Test + fun givenEmptyWelcomeOrNoRoute_whenResolvingPolicy_thenStartupFlagsAreExact() { + val empty = WireNavigation3ActivityPolicy.sessionSnapshot( + route = NewWelcomeEmptyStartRoute(), + hasCurrentSession = false, + isUserUiBlocked = false, + isSessionTransitionInProgress = false, + isSelfLogoutTransition = false, + ) + val absent = WireNavigation3ActivityPolicy.sessionSnapshot( + route = null, + hasCurrentSession = false, + isUserUiBlocked = false, + isSessionTransitionInProgress = false, + isSelfLogoutTransition = false, + ) + + assertTrue(empty.isEmptyWelcomeRoute) + assertTrue(empty.hasCurrentRoute) + assertFalse(absent.hasCurrentRoute) + assertEquals(BackgroundType.Default, WireNavigation3ActivityPolicy.backgroundType(null)) + } + + private data object TestSessionAuthenticationRoute : + AuthenticationScreenRoute, + SessionRoute { + override val sessionId = WireSessionId("user", "wire.example") + override val entryId = WireNavEntryId("entry") + override val routeId = "session-auth" + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt new file mode 100644 index 00000000000..c8350ce70c3 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt @@ -0,0 +1,152 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireNavigation3ContributionsTest { + + @Test + fun givenAllMigratedContributions_whenCollectingResultTypes_thenContractIdsAreUniqueAndComplete() { + val contractIds = WireNavigation3Contributions.resultTypes() + .map { it.contract.id.value } + + assertEquals( + listOf( + "new-conversation.channel-history.custom", + "settings.account-update", + "user-profile.avatar-picker", + "user-profile.connection-request-ignored", + "conversation.completion", + "conversation-folders.completed", + "conversation-folders.created", + "conversation-details.edit-name", + "conversation-details.channel-access", + "media.images-preview", + "media.gallery", + "cells.boolean", + "cells.public-link-expiration", + "sketch.drawing-canvas", + ), + contractIds, + ) + assertEquals(contractIds.size, contractIds.toSet().size) + } + + @Test + fun givenAggregatorSource_whenInspectingComposition_thenContributionOrderIsDeterministic() { + val source = appSource("navigation/runtime/WireNavigation3Contributions.kt").readText() + val orderedMarkers = listOf( + "AuthenticationNavigation3Contribution.entryProviderInstallers(actions, authenticationRouter)", + "HomeNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "add(newConversationNavigation3Entries(runtime, actions))", + "ChannelHistoryNavigation3Pilot.entryProviderInstallers(runtime)", + "SettingsNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "DeviceE2EINavigation3Contribution.entryProviderInstallers(runtime, actions, authenticationRouter)", + "UserProfileNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "TeamMigrationNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "ConversationNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "ConversationAuxNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "ConversationDetailsNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "MediaNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "UtilityNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "AppLockNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "CellsNavigation3Contribution.entryProviderInstallers(runtime, actions::exitCellsFlow)", + "MeetingsNavigation3Contribution.entryProviderInstallers(runtime, actions)", + "SketchNavigation3Contribution.entryProviderInstallers(runtime)", + ) + + val positions = orderedMarkers.map { marker -> + source.indexOf(marker).also { + assertTrue(it >= 0, "Missing contribution marker $marker") + } + } + assertEquals(positions.sorted(), positions) + assertTrue(source.contains("EXPECTED_INSTALLER_COUNT: Int = 19")) + } + + @Test + fun givenIncludedContributionSources_whenCountingTypedEntries_thenExpectedRegistrationCountIsStable() { + val registrationCount = appContributionSources.sumOf { relativePath -> + Regex("""wireEntry<""") + .findAll(appSource(relativePath).readText()) + .count() + } + featureContributionSources.sumOf { relativePath -> + Regex("""wireEntry<""") + .findAll(projectSource(relativePath).readText()) + .count() + } + + assertEquals(WireNavigation3Contributions.EXPECTED_ROUTE_REGISTRATION_COUNT, registrationCount) + assertEquals(106, registrationCount) + } + + @Test + fun givenAggregatorSource_whenInspectingImports_thenNoLegacyNavigationApiLeaksIntoCompositionRoot() { + val source = appSource("navigation/runtime/WireNavigation3Contributions.kt").readText() + + listOf( + "com.ramcosta.composedestinations", + "androidx.navigation.NavController", + "android.os.Bundle", + "ScreenDestination", + "DEFAULT_ARGS_KEY", + "SavedStateHandle", + ).forEach { forbidden -> + assertFalse(source.contains(forbidden), "Aggregator must not reference $forbidden") + } + assertTrue(source.contains("interface WireNavigation3CompositeActions")) + assertTrue(source.contains("override val topLevel: HomeTopLevelNavigation3Actions")) + assertTrue(source.contains("override val settings: SettingsNavigation3Actions")) + } + + private fun appSource(relativePath: String): File { + return projectSource("app/src/main/kotlin/com/wire/android/$relativePath") + } + + private fun projectSource(relativePath: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(projectDir, relativePath).also { + assertTrue(it.isFile, "Missing source file $relativePath") + } + } + + private companion object { + val appContributionSources = listOf( + "navigation/routes/auth/AuthenticationNavigation3Entries.kt", + "navigation/routes/auth/CreateAccountNavigation3Entries.kt", + "ui/home/HomeNavigation3Entry.kt", + "ui/home/newconversation/NewConversationNavigation3Entries.kt", + "ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Entries.kt", + "ui/home/settings/SettingsNavigation3Entries.kt", + "ui/home/settings/SettingsAccountNavigation3Entries.kt", + "ui/settings/devices/DeviceE2EINavigation3Entries.kt", + "ui/userprofile/UserProfileNavigation3Entries.kt", + "ui/userprofile/teammigration/TeamMigrationNavigation3Entries.kt", + "ui/home/conversations/ConversationNavigation3Entries.kt", + "ui/home/conversations/ConversationAuxNavigation3Entries.kt", + "ui/home/conversations/details/ConversationDetailsNavigation3Entries.kt", + "navigation/routes/media/MediaNavigation3Entries.kt", + "navigation/routes/utility/UtilityNavigation3Entries.kt", + "ui/home/appLock/AppLockNavigation3Entries.kt", + ) + val featureContributionSources = listOf( + "features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt", + "features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Entries.kt", + "features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsSourceTest.kt new file mode 100644 index 00000000000..9c45c354162 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsSourceTest.kt @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class WireNavigation3ProductionActionsSourceTest { + + @Test + fun `production actions keep Activity and generated navigation behind semantic callbacks`() { + val source = sourceFile().readText() + + assertFalse(source.contains("com.ramcosta.composedestinations.generated")) + assertFalse(source.contains("NavController")) + assertFalse(source.contains("android.content.Intent")) + assertFalse(source.contains("WireActivity")) + assertFalse(source.contains("PendingFolderResult")) + assertFalse(source.contains("PendingDrawingResult")) + assertFalse(source.contains("pendingFolderResults")) + assertFalse(source.contains("pendingDrawingResults")) + assertFalse(source.contains("callback:")) + assertTrue(source.contains("override fun restartAfterLogout() = activity.restartAfterLogout()")) + assertFalse(source.contains("override fun restartAfterLogout() = activity.hardLogout()")) + } + + @Test + fun `initial sync waits for typed Home before moving task to background`() { + val source = sourceFile().readText() + + assertTrue(source.contains("runtime.navigator.currentRoute as? HomeRoute")) + assertTrue(source.contains("activity.moveTaskToBackground()")) + } + + private fun sourceFile() = File( + "src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt" + ) +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt new file mode 100644 index 00000000000..c64eacca9f2 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt @@ -0,0 +1,228 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.feature.cells.navigation.ConversationFilesRoute +import com.wire.android.feature.meetings.ui.create.NewMeetingDetailsRoute +import com.wire.android.feature.meetings.ui.create.NewMeetingRouteType +import com.wire.android.feature.meetings.ui.create.NewMeetingType +import com.wire.android.navigation.LoginTypeSelector +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.routes.auth.AuthenticationLoginCompletion +import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router +import com.wire.android.navigation.routes.auth.CreateAccountUsernameRoute +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.media.ConversationMediaRoute +import com.wire.android.navigation.routes.media.MediaConversationId +import com.wire.android.navigation.routes.utility.DebugRoute +import com.wire.android.navigation.routes.utility.InitialSyncRoute +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute +import com.wire.android.ui.authentication.devices.register.RegisterDeviceRoute +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute +import com.wire.android.ui.home.HomeRequirement +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.home.conversations.details.ConversationDetailsId +import com.wire.android.ui.home.settings.SettingsNavigation3Destination +import com.wire.android.ui.settings.devices.SelfDevicesRoute +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireNavigationController +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +internal class WireNavigation3ProductionActionsTest { + + @Test + fun `given every login completion, when resolving, then target is typed and whole stack is cleared`() { + val cases = listOf( + AuthenticationLoginCompletion.Home(OtherSession) to HomeRoute::class.java, + AuthenticationLoginCompletion.InitialSync(OtherSession) to InitialSyncRoute::class.java, + AuthenticationLoginCompletion.E2EIEnrollment(OtherSession) to E2EIEnrollmentRoute::class.java, + AuthenticationLoginCompletion.RemoveDevice(OtherSession) to RemoveDeviceRoute::class.java, + ) + + cases.forEach { (completion, expectedType) -> + val command = WireNavigation3ProductionCommandResolver.completeLogin(completion) + + assertInstanceOf(expectedType, command.destination) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + } + } + + @Test + fun `given session carried by device completion, when resolving, then completion uses that session`() { + val command = WireNavigation3ProductionCommandResolver.completeLogin( + AuthenticationLoginCompletion.RemoveDevice(OtherSession), + ) + + assertEquals(OtherSession, (command.destination as RemoveDeviceRoute).sessionId) + } + + @Test + fun `given login completion carries a new session, when resolving, then that session is used`() { + val command = WireNavigation3ProductionCommandResolver.completeLogin( + AuthenticationLoginCompletion.Home(OtherSession), + ) + + assertEquals(OtherSession, (command.destination as HomeRoute).sessionId) + } + + @Test + fun `given home requirements, when resolving, then legacy clear whole semantics are preserved`() { + val register = WireNavigation3ProductionCommandResolver.homeRequirement( + HomeRequirement.RegisterDevice(UserId("other", "wire.test")), + currentSessionId = null, + ) + val username = WireNavigation3ProductionCommandResolver.homeRequirement( + HomeRequirement.CreateAccountUsername, + currentSessionId = Session, + ) + + assertInstanceOf(RegisterDeviceRoute::class.java, register.destination) + assertEquals(Session, (username.destination as CreateAccountUsernameRoute).sessionId) + assertEquals(WireBackStackMode.CLEAR_WHOLE, register.backStackMode) + assertEquals(WireBackStackMode.CLEAR_WHOLE, username.backStackMode) + } + + @Test + fun `given settings target, when resolving, then app owned targets use typed routes and external targets return null`() { + val devices = WireNavigation3ProductionCommandResolver.settings( + SettingsNavigation3Destination.MANAGE_DEVICES, + Session, + ) + val debug = WireNavigation3ProductionCommandResolver.settings( + SettingsNavigation3Destination.DEBUG_SETTINGS, + Session, + ) + val support = WireNavigation3ProductionCommandResolver.settings( + SettingsNavigation3Destination.SUPPORT, + Session, + ) + + assertInstanceOf(SelfDevicesRoute::class.java, devices?.destination) + assertInstanceOf(DebugRoute::class.java, debug?.destination) + assertNull(support) + } + + @Test + fun `given imported share, when opening conversation, then share entry is replaced without clearing parent stack`() { + val command = WireNavigation3ProductionCommandResolver.conversationFromShare( + Session, + MediaConversationId("conversation", "wire.test"), + assets = emptyList(), + text = "shared text", + ) + + assertInstanceOf(ConversationRoute::class.java, command.destination) + assertEquals(WireBackStackMode.REMOVE_CURRENT_AND_REPLACE, command.backStackMode) + } + + @Test + fun `given meeting type, when opening meeting flow, then typed details route is navigated directly`() { + val (actions, navigator) = productionActions() + val command = slot() + every { navigator.navigate(capture(command)) } returns true + + listOf( + NewMeetingType.MeetNow to NewMeetingRouteType.MEET_NOW, + NewMeetingType.Schedule to NewMeetingRouteType.SCHEDULE, + ).forEach { (legacyType, expectedType) -> + actions.openNewMeeting(legacyType) + + val route = assertInstanceOf(NewMeetingDetailsRoute::class.java, command.captured.destination) + assertEquals(Session, route.sessionId) + assertEquals(expectedType, route.type) + assertEquals(WireBackStackMode.NONE, command.captured.backStackMode) + } + } + + @Test + fun `given Cells conversation, when opening details media action, then Shared Drive is opened`() { + val (actions, navigator) = productionActions() + val command = slot() + every { navigator.navigate(capture(command)) } returns true + + actions.openConversationMedia( + conversationId = ConversationDetailsId("conversation", "wire.test"), + isCellsConversation = true, + groupName = "Project", + ) + + val route = assertInstanceOf(ConversationFilesRoute::class.java, command.captured.destination) + assertEquals(Session, route.sessionId) + assertEquals("conversation@wire.test", route.args.conversationId) + assertEquals(listOf("Project"), route.args.breadcrumbs) + } + + @Test + fun `given regular conversation, when opening details media action, then Media is opened`() { + val (actions, navigator) = productionActions() + val command = slot() + every { navigator.navigate(capture(command)) } returns true + + actions.openConversationMedia( + conversationId = ConversationDetailsId("conversation", "wire.test"), + isCellsConversation = false, + groupName = "Project", + ) + + val route = assertInstanceOf(ConversationMediaRoute::class.java, command.captured.destination) + assertEquals(Session, route.sessionId) + assertEquals(MediaConversationId("conversation", "wire.test"), route.conversationId) + } + + @Test + fun `given new login is already visible, when no other account remains, then navigation is not repeated`() { + val (actions, navigator) = productionActions( + currentRoute = NewLoginRoute.start(), + useNewLogin = true, + ) + + actions.noOtherAccountToSwitch() + + verify(exactly = 0) { navigator.navigate(any()) } + } + + private fun productionActions( + currentRoute: WireRoute = HomeRoute(Session), + useNewLogin: Boolean = false, + ): Triple { + val runtime = mockk() + val navigator = mockk() + val loginTypeSelector = mockk() + every { runtime.navigator } returns navigator + every { navigator.currentRoute } returns currentRoute + every { loginTypeSelector.canUseNewLogin() } returns useNewLogin + val actions = WireNavigation3ProductionActions( + runtime = runtime, + activity = mockk(relaxed = true), + currentSessionId = { Session }, + loginTypeSelector = loginTypeSelector, + authenticationRouter = AuthenticationNavigation3Router(runtime), + ) + return Triple(actions, navigator, runtime) + } + + private companion object { + val Session = WireSessionId("self", "wire.test") + val OtherSession = WireSessionId("other", "wire.test") + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHostSourceTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHostSourceTest.kt new file mode 100644 index 00000000000..159135c8820 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHostSourceTest.kt @@ -0,0 +1,71 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireNavigation3ProductionHostSourceTest { + + @Test + fun givenProductionHost_whenInspectingSource_thenItHasOneTypedCatalogAndNoLegacyHost() { + val source = sourceFile().readText() + + assertTrue("WireNavigation3Contributions.create(runtime, actions, authenticationRouter)" in source) + assertTrue("WireNav3Host(" in source) + assertTrue("onRootBack: () -> Unit" in source) + assertTrue("navigateBackOrRunRootFallback(runtime.navigator::goBack, onRootBack)" in source) + listOf( + "MainNavHost", + "DestinationsNavHost", + "NavController", + "com.ramcosta", + "NavigationCommand", + "Bundle", + ).forEach { forbidden -> assertFalse(forbidden in source, forbidden) } + } + + @Test + fun givenBackStackCanPop_whenHandlingSystemBack_thenRootFallbackIsNotCalled() { + var rootBackCalls = 0 + + navigateBackOrRunRootFallback( + navigateBack = { true }, + onRootBack = { rootBackCalls++ }, + ) + + assertEquals(0, rootBackCalls) + } + + @Test + fun givenBackStackIsAtRoot_whenHandlingSystemBack_thenRootFallbackIsCalled() { + var rootBackCalls = 0 + + navigateBackOrRunRootFallback( + navigateBack = { false }, + onRootBack = { rootBackCalls++ }, + ) + + assertEquals(1, rootBackCalls) + } + + private fun sourceFile(): File { + val relative = "src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionHost.kt" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentationTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentationTest.kt new file mode 100644 index 00000000000..250ad9b0131 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ResponsivePresentationTest.kt @@ -0,0 +1,106 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.ui.home.settings.ChangeUserColorRoute +import com.wire.android.feature.cells.navigation.ConversationFilesRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +internal class WireNavigation3ResponsivePresentationTest { + + @Test + fun `given tablet dialog routes, when inspecting typed policy mapping, then all routes are unique`() { + assertEquals(17, wireNavigation3TabletDialogRouteTypes.size) + assertEquals( + setOf( + "ServiceDetailsRoute", + "OtherUserProfileRoute", + "SelfUserProfileRoute", + "DeviceDetailsRoute", + "ChangeDisplayNameRoute", + "ChangeHandleRoute", + "ChangeEmailRoute", + "AvatarPickerRoute", + "GroupConversationDetailsRoute", + "EditConversationNameRoute", + "EditGuestAccessRoute", + "UpdateAppsAccessRoute", + "ChannelAccessOnUpdateRoute", + "EditSelfDeletingMessagesRoute", + "ConversationFoldersRoute", + "NewConversationFolderRoute", + "ChangeUserColorRoute", + ), + wireNavigation3TabletDialogRouteTypes.mapNotNull { it.simpleName }.toSet(), + ) + assertFalse(ConversationFilesRoute::class in wireNavigation3TabletDialogRouteTypes) + } + + @Test + fun `given tablet dialog typed route, when classifying presentation, then only tablet uses dialog`() { + val route = ChangeUserColorRoute(TestSessionId) + + assertNotSame( + WireEntryPresentation.Default, + WireNavigation3ResponsivePresentationPolicy.resolve( + route = route, + isTablet = true, + ), + ) + assertSame( + WireEntryPresentation.Default, + WireNavigation3ResponsivePresentationPolicy.resolve( + route = route, + isTablet = false, + ), + ) + } + + @Test + fun `given route outside tablet dialog list, when classifying presentation, then it remains default`() { + val route = NonDialogRoute(TestSessionId) + + assertSame( + WireEntryPresentation.Default, + WireNavigation3ResponsivePresentationPolicy.resolve( + route = route, + isTablet = true, + ), + ) + } + + private data class NonDialogRoute( + override val sessionId: WireSessionId, + override val entryId: WireNavEntryId = WireNavEntryId("non-dialog-entry"), + ) : SessionRoute { + override val routeId: String = "test/non-dialog" + } + + private companion object { + val TestSessionId = WireSessionId(value = "user", domain = "wire.test") + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt new file mode 100644 index 00000000000..45cabefa366 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt @@ -0,0 +1,92 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.runtime + +import com.wire.android.navigation.routes.auth.AuthenticationServerLinks +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class WireNavigation3SwitchAccountActionsTest { + + @Test + fun givenAccountWasSwitched_whenNotified_thenOnlyTypedSwitchCallbackRuns() { + var switched = 0 + var empty = 0 + val actions = WireNavigation3SwitchAccountActions({ switched++ }, { empty++ }) + + actions.switchedToAnotherAccount() + + assertEquals(1, switched) + assertEquals(0, empty) + } + + @Test + fun givenNoAccountRemains_whenNotified_thenOnlyTypedEmptyCallbackRuns() { + var switched = 0 + var empty = 0 + val actions = WireNavigation3SwitchAccountActions({ switched++ }, { empty++ }) + + actions.noOtherAccountToSwitch() + + assertEquals(0, switched) + assertEquals(1, empty) + } + + @Test + fun givenNewLoginIsAlreadyVisible_whenNoOtherAccountRemains_thenDoNotNavigateAgain() { + val command = noOtherAccountNavigationCommand( + currentRoute = NewLoginRoute.start(), + useNewLogin = true, + ) + + assertNull(command) + } + + @Test + fun givenSessionRouteIsVisible_whenNoOtherAccountRemains_thenClearStackToNewLogin() { + val command = noOtherAccountNavigationCommand( + currentRoute = HomeRoute(WireSessionId("user", "example.com")), + useNewLogin = true, + ) + + assertInstanceOf(NewLoginRoute::class.java, command?.destination) + } + + @Test + fun givenRemovedSessionBackend_whenNoOtherAccountRemains_thenPreserveBackendInNewLogin() { + val serverLinks = AuthenticationServerLinks( + api = "https://staging.example.com", + accounts = "https://accounts.example.com", + webSocket = "https://websocket.example.com", + blackList = "https://blacklist.example.com", + teams = "https://teams.example.com", + website = "https://www.example.com", + title = "Staging", + isOnPremises = false, + apiProxy = null, + supportEmail = null, + ) + + val command = noOtherAccountNavigationCommand( + currentRoute = HomeRoute(WireSessionId("user", "example.com")), + useNewLogin = true, + recoveryServerLinks = serverLinks, + ) + + val destination = assertInstanceOf(NewLoginRoute::class.java, command?.destination) + assertEquals(serverLinks, destination.args.loginPasswordPath?.customServerConfig) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnosticsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnosticsTest.kt new file mode 100644 index 00000000000..d3ba55ff1b0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigationDiagnosticsTest.kt @@ -0,0 +1,71 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +package com.wire.android.navigation.runtime + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class WireNavigationDiagnosticsTest { + + @Test + fun givenViewModelKeyContainsInstanceData_whenRedacting_thenOnlyTypeIsExposed() { + val key = "com.wire.ConversationViewModel:conversation-id@backend.example" + + assertEquals("com.wire.ConversationViewModel", key.redactedViewModelKey()) + } + + @Test + fun givenOneActiveOwnerKey_whenAnotherStoreClaimsIt_thenInvariantFails() { + val firstOwner = TestOwner() + val conflictingOwner = TestOwner() + val ownerKey = "flow:diagnostics-collision-test" + + try { + WireNavigationDiagnostics.ownerAvailable(firstOwner, ownerKey) + + assertThrows(IllegalStateException::class.java) { + WireNavigationDiagnostics.ownerAvailable(conflictingOwner, ownerKey) + } + } finally { + WireNavigationDiagnostics.ownerReleased(firstOwner, ownerKey) + WireNavigationDiagnostics.ownerCleared(ownerKey) + } + } + + @Test + fun givenOneOwnerInstance_whenItClaimsAnotherKey_thenInvariantFails() { + val owner = TestOwner() + val firstKey = "flow:diagnostics-first-key" + + try { + WireNavigationDiagnostics.ownerAvailable(owner, firstKey) + + assertThrows(IllegalStateException::class.java) { + WireNavigationDiagnostics.ownerAvailable(owner, "flow:diagnostics-second-key") + } + } finally { + WireNavigationDiagnostics.ownerReleased(owner, firstKey) + WireNavigationDiagnostics.ownerCleared(firstKey) + } + } + + private class TestOwner : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolverTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolverTest.kt new file mode 100644 index 00000000000..639be7ac35f --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireRouteScopeResolverTest.kt @@ -0,0 +1,105 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import com.wire.navigation.AuthenticationRoute +import com.wire.navigation.AuthenticationScreenRoute +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WireRouteScopeResolverTest { + + @Test + fun givenAuthenticationRoute_whenResolvingScope_thenAuthenticationScopeIsReturned() { + val result = WireRouteScopeResolver.resolve(TestAuthenticationRoute) + + assertEquals(WireRouteScope.Authentication, result) + } + + @Test + fun givenSessionRoute_whenResolvingScope_thenMatchingSessionScopeIsReturned() { + val sessionId = WireSessionId(value = "user", domain = "wire.test") + val result = WireRouteScopeResolver.resolve(TestSessionRoute(sessionId)) + + assertEquals(WireRouteScope.Session(sessionId), result) + } + + @Test + fun givenSessionBackedAuthenticationScreen_whenResolvingScope_thenSessionScopeIsReturned() { + val result = WireRouteScopeResolver.resolve(TestSessionBackedAuthenticationRoute) + + assertEquals( + WireRouteScope.Session(TestSessionBackedAuthenticationRoute.sessionId), + result, + ) + } + + @Test + fun givenUnscopedRoute_whenResolvingScope_thenUnavailableIsReturned() { + val result = WireRouteScopeResolver.resolve(TestUnscopedRoute) + + assertEquals( + WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.ROUTE_HAS_NO_SCOPE), + result, + ) + } + + @Test + fun givenRouteWithBothScopeMarkers_whenResolvingScope_thenUnavailableIsReturned() { + val result = WireRouteScopeResolver.resolve(TestAmbiguousRoute) + + assertEquals( + WireRouteScope.Unavailable(WireRouteScope.Unavailable.Reason.ROUTE_HAS_MULTIPLE_SCOPES), + result, + ) + } + + private data object TestAuthenticationRoute : AuthenticationRoute { + override val routeId: String = "authentication" + override val entryId: WireNavEntryId = WireNavEntryId("authentication-entry") + } + + private data class TestSessionRoute(override val sessionId: WireSessionId) : SessionRoute { + override val routeId: String = "session" + override val entryId: WireNavEntryId = WireNavEntryId.random() + } + + private data object TestSessionBackedAuthenticationRoute : + SessionRoute, + AuthenticationScreenRoute { + override val routeId: String = "session-backed-authentication" + override val entryId = WireNavEntryId("session-backed-authentication-entry") + override val sessionId = WireSessionId(value = "user", domain = "wire.test") + } + + private data object TestUnscopedRoute : WireRoute { + override val routeId: String = "unscoped" + override val entryId: WireNavEntryId = WireNavEntryId("unscoped-entry") + } + + private data object TestAmbiguousRoute : AuthenticationRoute, SessionRoute { + override val routeId: String = "ambiguous" + override val entryId: WireNavEntryId = WireNavEntryId("ambiguous-entry") + override val sessionId: WireSessionId = WireSessionId(value = "user", domain = "wire.test") + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicyTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicyTest.kt new file mode 100644 index 00000000000..83d6cf5b3a3 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireSessionNavigationPolicyTest.kt @@ -0,0 +1,140 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WireSessionNavigationPolicyTest { + + @Test + fun givenUserUiIsBlocked_whenResolvingSessionNavigation_thenWaitForDialogTakesPrecedence() { + val decision = resolve { + copy(hasCurrentSession = true, isEmptyWelcomeRoute = true, isUserUiBlocked = true) + } + + assertEquals(WireSessionNavigationDecision.WAIT_FOR_BLOCKING_DIALOG, decision) + } + + @Test + fun givenSessionExistsOnEmptyWelcome_whenResolvingSessionNavigation_thenNavigateHomeClearingStack() { + val decision = resolve { + copy(hasCurrentSession = true, isEmptyWelcomeRoute = true) + } + + assertEquals(WireSessionNavigationDecision.NAVIGATE_HOME_CLEAR_STACK, decision) + } + + @Test + fun givenSessionIsMissingOnEmptyWelcome_whenResolvingSessionNavigation_thenNavigateLoginClearingStack() { + val decision = resolve { copy(isEmptyWelcomeRoute = true) } + + assertEquals(WireSessionNavigationDecision.NAVIGATE_LOGIN_CLEAR_STACK, decision) + } + + @Test + fun givenSessionIsMissingOnSessionBackedAuthentication_whenResolvingSessionNavigation_thenNavigateLogin() { + val decision = resolve { + copy( + isAuthenticationRoute = true, + isSessionBackedAuthenticationRoute = true, + isSessionTransitionInProgress = true, + ) + } + + assertEquals(WireSessionNavigationDecision.NAVIGATE_LOGIN_CLEAR_STACK, decision) + } + + @Test + fun givenNonAuthenticationRouteDuringAccountTransition_whenResolvingSessionNavigation_thenResolveCurrentSession() { + val decision = resolve { + copy( + hasCurrentSession = true, + hasCurrentRoute = true, + isSessionTransitionInProgress = true, + ) + } + + assertEquals(WireSessionNavigationDecision.RESOLVE_CURRENT_SESSION, decision) + } + + @Test + fun givenSelfLogoutTransition_whenResolvingSessionNavigation_thenDoNothing() { + val decision = resolve { + copy( + hasCurrentRoute = true, + isSessionTransitionInProgress = true, + isSelfLogoutTransition = true, + ) + } + + assertEquals(WireSessionNavigationDecision.NONE, decision) + } + + @Test + fun givenAuthenticationRouteDuringTransition_whenResolvingSessionNavigation_thenDoNothing() { + val decision = resolve { + copy( + hasCurrentRoute = true, + isAuthenticationRoute = true, + isSessionTransitionInProgress = true, + ) + } + + assertEquals(WireSessionNavigationDecision.NONE, decision) + } + + @Test + fun givenSessionIsMissingOnOrdinaryRoute_whenResolvingSessionNavigation_thenResolveCurrentSession() { + val decision = resolve { copy(hasCurrentRoute = true) } + + assertEquals(WireSessionNavigationDecision.RESOLVE_CURRENT_SESSION, decision) + } + + @Test + fun givenCurrentRouteIsMissing_whenResolvingSessionNavigation_thenDoNothing() { + val decision = resolve() + + assertEquals(WireSessionNavigationDecision.NONE, decision) + } + + @Test + fun givenValidSessionOnOrdinaryRoute_whenResolvingSessionNavigation_thenDoNothing() { + val decision = resolve { copy(hasCurrentSession = true, hasCurrentRoute = true) } + + assertEquals(WireSessionNavigationDecision.NONE, decision) + } + + private fun resolve( + transform: WireSessionNavigationSnapshot.() -> WireSessionNavigationSnapshot = { this }, + ): WireSessionNavigationDecision = WireSessionNavigationPolicy.resolve(DEFAULT_SNAPSHOT.transform()) + + private companion object { + val DEFAULT_SNAPSHOT = WireSessionNavigationSnapshot( + hasCurrentSession = false, + hasCurrentRoute = false, + isEmptyWelcomeRoute = false, + isAuthenticationRoute = false, + isSessionBackedAuthenticationRoute = false, + isUserUiBlocked = false, + isSessionTransitionInProgress = false, + isSelfLogoutTransition = false, + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolverTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolverTest.kt new file mode 100644 index 00000000000..b6337106351 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/startup/WireInitialRouteResolverTest.kt @@ -0,0 +1,123 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.runtime.startup + +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.ui.InitialAppState +import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class WireInitialRouteResolverTest { + + @Test + fun givenLoggedOutAndNewLoginEnabled_whenResolving_thenNewWelcomeRouteIsReturned() { + val result = WireInitialRouteResolver.resolve( + initialAppState = InitialAppState.NotLoggedIn, + loginType = WireStartupLoginType.NEW, + activeSessionId = null, + ) + + assertEquals(NewWelcomeEmptyStartRoute.ROUTE_ID, result.routeId) + assertEquals(NewWelcomeEmptyStartRoute::class, result::class) + } + + @Test + fun givenLoggedOutAndLegacyLoginSelected_whenResolving_thenLegacyWelcomeRouteIsReturned() { + val result = WireInitialRouteResolver.resolve( + initialAppState = InitialAppState.NotLoggedIn, + loginType = WireStartupLoginType.LEGACY, + activeSessionId = null, + ) + + assertEquals(WelcomeRoute.ROUTE_ID, result.routeId) + assertEquals(WelcomeRoute::class, result::class) + } + + @Test + fun givenE2eiEnrollmentRequired_whenResolving_thenRouteOwnsTheRequestedSession() { + val userId = UserId("enrolling-user", "wire.test") + + val result = WireInitialRouteResolver.resolve( + initialAppState = InitialAppState.EnrollE2EI(userId), + loginType = WireStartupLoginType.NEW, + activeSessionId = WireSessionId("different-user", "wire.test"), + ) as E2EIEnrollmentRoute + + assertEquals(WireSessionId("enrolling-user", "wire.test"), result.sessionId) + assertEquals(E2EIEnrollmentRoute.ROUTE_ID, result.routeId) + } + + @Test + fun givenLoggedIn_whenResolving_thenHomeRouteOwnsTheActiveSession() { + val sessionId = WireSessionId("active-user", "wire.test") + + val result = WireInitialRouteResolver.resolve( + initialAppState = InitialAppState.LoggedIn, + loginType = WireStartupLoginType.NEW, + activeSessionId = sessionId, + ) as HomeRoute + + assertEquals(sessionId, result.sessionId) + assertEquals("app/home_screen", result.routeId) + } + + @Test + fun givenLoggedInWithoutActiveSession_whenResolving_thenInvalidStateIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + WireInitialRouteResolver.resolve( + initialAppState = InitialAppState.LoggedIn, + loginType = WireStartupLoginType.NEW, + activeSessionId = null, + ) + } + } + + @Test + fun givenLoginCapability_whenSelectingLoginType_thenBooleanMeaningIsPreserved() { + assertEquals( + WireStartupLoginType.NEW, + WireStartupLoginType.fromCanUseNewLogin(true), + ) + assertEquals( + WireStartupLoginType.LEGACY, + WireStartupLoginType.fromCanUseNewLogin(false), + ) + } + + @Test + fun givenHomeRoute_whenSerializedAndRestored_thenSessionAndEntryIdentityArePreserved() { + val route = HomeRoute( + sessionId = WireSessionId("active-user", "wire.test"), + entryId = WireNavEntryId("home-entry"), + ) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals("app/home_screen", restored.routeId) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt index 65bde76d941..a3ff54fb5cb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt @@ -34,6 +34,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -43,13 +44,13 @@ import org.junit.jupiter.api.Test class CallActivityViewModelTest { @Test - fun `given no current, when checking screenshot censoring config, then return false`() = + fun givenRequestedUser_whenScreenshotCensoringIsDisabled_thenReturnFalse() = runTest { val (_, viewModel) = Arrangement() - .withCurrentSessionReturning(CurrentSessionResult.Failure.SessionNotFound) + .withScreenshotCensoringConfigReturning(ObserveScreenshotCensoringConfigResult.Disabled) .arrange() - val result = viewModel.isScreenshotCensoringConfigEnabled() + val result = viewModel.isScreenshotCensoringConfigEnabled(userId) assertEquals(false, result.await()) } @@ -62,7 +63,7 @@ class CallActivityViewModelTest { .withScreenshotCensoringConfigReturning(ObserveScreenshotCensoringConfigResult.Enabled.ChosenByUser) .arrange() - val result = viewModel.isScreenshotCensoringConfigEnabled() + val result = viewModel.isScreenshotCensoringConfigEnabled(userId) assertEquals(true, result.await()) } @@ -75,11 +76,28 @@ class CallActivityViewModelTest { .withScreenshotCensoringConfigReturning(ObserveScreenshotCensoringConfigResult.Disabled) .arrange() - val result = viewModel.isScreenshotCensoringConfigEnabled() + val result = viewModel.isScreenshotCensoringConfigEnabled(userId) assertEquals(false, result.await()) } + @Test + fun givenRequestedAccountDiffersFromCurrent_whenCheckingScreenshotPolicy_thenRequestedAccountIsUsed() = + runTest { + val otherUser = UserId("other", "domain") + val (arrangement, viewModel) = Arrangement() + .withCurrentSessionReturning(CurrentSessionResult.Success(AccountInfo.Valid(otherUser))) + .withScreenshotCensoringConfigReturning(ObserveScreenshotCensoringConfigResult.Disabled) + .arrange() + + viewModel.isScreenshotCensoringConfigEnabled(userId).await() + + coVerify(exactly = 0) { arrangement.currentSession() } + verify(exactly = 1) { + arrangement.observeScreenshotCensoringConfigUseCaseProviderFactory.create(userId) + } + } + @Test fun `given no session available, when trying to switch account, then try to switchAccount usecase once`() = runTest { @@ -174,11 +192,11 @@ class CallActivityViewModelTest { private class Arrangement { @MockK - private lateinit var observeScreenshotCensoringConfigUseCaseProviderFactory: + lateinit var observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory @MockK - private lateinit var currentSession: CurrentSessionUseCase + lateinit var currentSession: CurrentSessionUseCase @MockK lateinit var accountSwitch: AccountSwitchUseCase diff --git a/app/src/test/kotlin/com/wire/android/ui/EntryOwnedViewModelGatewaySourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/EntryOwnedViewModelGatewaySourceTest.kt new file mode 100644 index 00000000000..33a5e5a90f0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/EntryOwnedViewModelGatewaySourceTest.kt @@ -0,0 +1,270 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class EntryOwnedViewModelGatewaySourceTest { + + @Test + fun givenProductionSources_whenInspectingResaca_thenOnlyLazyMessageItemsUseScopedArgs() { + val root = repositoryRoot() + val allowed = setOf( + "core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt", + "app/src/main/kotlin/com/wire/android/media/audiomessage/AudioMessageViewModel.kt", + "app/src/main/kotlin/com/wire/android/ui/home/conversations/ScopedMessageViewModelGraph.kt", + "app/src/main/kotlin/com/wire/android/ui/home/conversations/model/CompositeMessageArgs.kt", + "app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/AssetLocalPathViewModel.kt", + "core/ui-common/src/main/kotlin/com/wire/android/ui/common/connection/ConnectionActionButtonArgs.kt", + ) + val offenders = productionSources(root) + .filter { it.readText().contains("ScopedArgs") } + .map { it.relativeTo(root).invariantSeparatorsPath } + .filterNot(allowed::contains) + + assertTrue(offenders.isEmpty(), "Resaca ownership leaked outside lazy message items: $offenders") + } + + @Test + fun givenProductionSources_whenCreatingSessionGraphs_thenOnlyRegistryOwnsFactoryAccess() { + val root = repositoryRoot() + val definition = "app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt" + val directCallOffenders = productionSources(root) + .filter { it.relativeTo(root).invariantSeparatorsPath != definition } + .filter { Regex("""(? + val source = sourceFile.readText() + val isMetroGateway = + sourceFile.invariantSeparatorsPath.endsWith("/core/di/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModel.kt") + val isItemScopeGateway = + sourceFile.invariantSeparatorsPath.endsWith("/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt") + val isRuntimeRegistryOwner = listOf( + "/app/src/main/kotlin/com/wire/android/navigation/runtime/SessionGraphStoreViewModel.kt", + // The Navigation 3 registry installs a lifecycle observer used only to report the + // actual clear after Lifecycle's provider releases every exit-animation token. + "/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreNavEntryDecorator.kt", + // CallActivityViewModel is intentionally Activity-owned. Metro's Provider only + // supplies the initial instance; the Activity remains its ViewModelStoreOwner. + "/app/src/main/kotlin/com/wire/android/ui/calling/CallActivity.kt", + ).any(sourceFile.invariantSeparatorsPath::endsWith) + + if (!isMetroGateway) { + assertFalse( + Regex("""\b(assistedMetroViewModel|metroViewModel)\s*[<(]""").containsMatchIn(source), + "Direct Metro ViewModel creation in ${sourceFile.relativeTo(root)}", + ) + assertFalse( + Regex("""\bViewModelProvider\s*\(""").containsMatchIn(source), + "Direct ViewModelProvider creation in ${sourceFile.relativeTo(root)}", + ) + } + if (!isItemScopeGateway) { + assertFalse( + source.contains("resacaMetroViewModelScoped"), + "Direct Resaca ViewModel creation in ${sourceFile.relativeTo(root)}", + ) + } + if (!isRuntimeRegistryOwner) { + assertFalse( + source.contains("androidx.lifecycle.viewmodel.compose.viewModel"), + "Direct framework ViewModel creation in ${sourceFile.relativeTo(root)}", + ) + assertFalse( + source.contains("viewModelFactory {") || source.contains("by viewModels"), + "Direct framework ViewModel factory in ${sourceFile.relativeTo(root)}", + ) + } + listOf( + "LocalWireFlowViewModelStoreOwner", + "LocalWireViewModelScopeKey", + "sessionKeyedMetroViewModel", + "sessionKeyedAssistedMetroViewModel", + "previousBackStackEntry", + "parentBackStackEntry", + "LastKnownCurrentAccount", + "com.ramcosta.composedestinations", + ).forEach { forbidden -> + assertFalse( + source.contains(forbidden), + "Legacy ownership API $forbidden in ${sourceFile.relativeTo(root)}", + ) + } + } + } + + @Test + fun givenFullyMigratedEntryHelpers_whenInspectingSources_thenOnlyWireGatewayDefinesIdentity() { + fullyMigratedSources().forEach { sourceFile -> + val source = sourceFile.readText() + + assertTrue( + source.contains("wireMetroViewModel") || source.contains("wireAssistedMetroViewModel"), + "Missing Wire ViewModel gateway in ${sourceFile.name}", + ) + assertFalse( + source.contains("sessionKeyed"), + "Legacy session-keyed gateway remains in ${sourceFile.name}", + ) + assertFalse( + source.contains("LocalWireViewModelScopeKey"), + "Graph identity remains part of entry ownership in ${sourceFile.name}", + ) + } + } + + @Test + fun givenPreviouslyDeferredHelpers_whenInspectingSources_thenLegacyGatewayIsGone() { + val home = sourceFile("com/wire/android/ui/home/HomeViewModelGraph.kt").readText() + val calling = sourceFile("com/wire/android/ui/calling/CallingManualViewModelFactory.kt").readText() + val misc = sourceFile("com/wire/android/ui/MiscViewModelGraph.kt").readText() + + listOf(home, calling, misc).forEach { source -> + assertFalse(source.contains("sessionKeyed")) + assertFalse(source.contains("LocalWireViewModelScopeKey")) + } + + listOf( + "fun analyticsUsageViewModel(): AnalyticsUsageViewModel =\n wireMetroViewModel()", + "fun importMediaAuthenticatedViewModel(): ImportMediaAuthenticatedViewModel =\n wireMetroViewModel()", + ).forEach { migratedLeaf -> + assertTrue(misc.contains(migratedLeaf), "Entry-owned Misc leaf regressed: $migratedLeaf") + } + assertTrue( + misc.contains( + "fun e2EIEnrollmentViewModel(\n" + + " owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current),\n" + + "): E2EIEnrollmentViewModel =\n" + + " wireMetroViewModel(owner = owner)" + ), + "Flow-owned E2EI ViewModel must use the explicit owner through the Wire gateway", + ) + } + + @Test + fun givenMultipleInstancesInsideOneEntry_whenInspectingSources_thenExplicitInstanceKeysArePreserved() { + val home = sourceFile("com/wire/android/ui/home/HomeViewModelGraph.kt").readText() + val calling = sourceFile("com/wire/android/ui/calling/CallingManualViewModelFactory.kt").readText() + val core = sourceFile( + "com/wire/android/ui/home/conversations/ConversationCoreViewModelGraph.kt" + ).readText() + val folders = sourceFile( + "com/wire/android/ui/home/conversations/ConversationSearchFolderViewModelGraph.kt" + ).readText() + + assertTrue(home.contains("instanceKey = \"list_\$conversationsSource\"")) + assertTrue(calling.contains("instanceKey = \"call_\$conversationsSource\"")) + assertTrue( + core.contains( + "conversationCoreViewModel(key = conversationKey)" + ) + ) + assertTrue(core.contains("conversationCoreViewModel(key = conversationKey)")) + assertTrue(core.contains("instanceKey = conversationId.value")) + assertTrue(folders.contains("instanceKey = \"conversation_folders_\${args.selectedFolderId}\"")) + assertTrue( + folders.contains( + "instanceKey = \"move_conversation_to_folder_\${args.conversationId}_\${args.currentFolderId}\"" + ) + ) + } + + @Test + fun givenConversationEntryOwnedTools_whenInspectingSources_thenResacaIsReservedForListItems() { + val source = sourceFile( + "com/wire/android/ui/home/conversations/ScopedMessageViewModelGraph.kt" + ).readText() + + listOf( + "messageOptionsMenuViewModel", + "typingIndicatorViewModel", + "selfDeletingMessageActionViewModel", + "isFileSharingEnabledViewModel", + "recordAudioViewModel", + ).forEach { functionName -> + val declarationStart = source.lastIndexOf("fun $functionName(") + assertTrue(declarationStart >= 0, "Missing $functionName") + val nextDeclaration = source.indexOf("\n@Composable", declarationStart + 1) + .takeIf { it >= 0 } + ?: source.length + val declaration = source.substring(declarationStart, nextDeclaration) + + assertTrue( + declaration.contains("wireAssistedMetroViewModel"), + "$functionName must use the entry-owned Wire gateway", + ) + assertFalse( + declaration.contains("wireManualMetroViewModelScoped") || + declaration.contains("scopedMessageViewModel"), + "$functionName must not use Resaca as a navigation ownership workaround", + ) + } + } + + private fun fullyMigratedSources(): List = + listOf( + "com/wire/android/ui/debug/DebugInfoViewModelGraph.kt", + "com/wire/android/ui/home/settings/SettingsViewModelGraph.kt", + "com/wire/android/ui/home/conversations/ConversationCoreViewModelGraph.kt", + "com/wire/android/ui/home/conversations/ConversationDetailsViewModelGraph.kt", + "com/wire/android/ui/home/conversations/ConversationSearchFolderViewModelGraph.kt", + ).map(::sourceFile) + + private fun productionSources(root: File): List = + listOf( + File(root, "app/src/main"), + File(root, "core"), + File(root, "features"), + ).flatMap { sourceRoot -> + sourceRoot.walkTopDown() + .filter { it.isFile && it.extension == "kt" && "/src/main/" in it.invariantSeparatorsPath } + .toList() + } + + private fun sourceFile(relativePath: String): File { + val root = repositoryRoot() + return File(root, "app/src/main/kotlin/$relativePath").also { + assertTrue(it.isFile, "Missing ${it.path}") + } + } + + private fun repositoryRoot(): File = + generateSequence(File(requireNotNull(System.getProperty("user.dir"))).absoluteFile) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt index bf9b9a5e8f1..cf8f66cd26a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt @@ -18,11 +18,8 @@ package com.wire.android.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory import com.wire.android.di.metro.AppSessionViewModelGraph +import com.wire.android.navigation.runtime.SessionGraphStoreViewModel import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.user.UserId import io.mockk.every @@ -30,80 +27,78 @@ import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.assertNotSame import org.junit.jupiter.api.Assertions.assertSame -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class SessionGraphStoreViewModelTest { @Test - fun givenSessionGraphIsInvalidated_whenSameUserLogsInAgain_thenGraphAndViewModelsAreRecreated() { + fun givenRemovedSessionIsConfirmedAvailable_whenSameUserLogsInAgain_thenGraphAndViewModelsAreRecreated() { val oldImageLoader = mockk(relaxed = true) val oldGraph = graphWith(oldImageLoader) val newGraph = graphWith(mockk(relaxed = true)) val graphs = ArrayDeque(listOf(oldGraph, newGraph)) - val store = SessionGraphStoreViewModel { graphs.removeFirst() } - val oldRetainedGraph = store.retainedGraphFor(USER_ID) - val oldViewModel = TrackingViewModel() - ViewModelProvider( - oldRetainedGraph, - viewModelFactory { initializer { oldViewModel } } - )[TrackingViewModel::class.java] + val store = SessionGraphStoreViewModel(createSessionGraph = { graphs.removeFirst() }) + val oldRetainedGraph = store.graphFor(USER_ID) - store.invalidate(USER_ID) - val newRetainedGraph = store.retainedGraphFor(USER_ID) + store.markInvalidating(USER_ID) + store.markRemoved(USER_ID) + store.markAvailable(USER_ID) + val newRetainedGraph = store.graphFor(USER_ID) assertNotSame(oldRetainedGraph, newRetainedGraph) - assertSame(newGraph, newRetainedGraph.graph) - assertTrue(oldViewModel.wasCleared) + assertSame(newGraph, newRetainedGraph) verify(exactly = 1) { oldImageLoader.shutdown() } } @Test - fun givenActivityIsRecreated_whenActiveGraphIsInvalidated_thenSameUserGetsFreshGraph() { + fun givenSessionIsRemovedAndConfirmedAgain_whenResolvingGraph_thenSameUserGetsFreshGraph() { val oldImageLoader = mockk(relaxed = true) val oldGraph = graphWith(oldImageLoader) val newGraph = graphWith(mockk(relaxed = true)) val graphs = ArrayDeque(listOf(oldGraph, newGraph)) - val store = SessionGraphStoreViewModel { graphs.removeFirst() } - val oldRetainedGraph = store.retainedGraphFor(USER_ID) + val store = SessionGraphStoreViewModel(createSessionGraph = { graphs.removeFirst() }) + val oldRetainedGraph = store.graphFor(USER_ID) - store.invalidateActive() - val newRetainedGraph = store.retainedGraphFor(USER_ID) + store.markInvalidating(USER_ID) + store.markRemoved(USER_ID) + store.markAvailable(USER_ID) + val newRetainedGraph = store.graphFor(USER_ID) assertNotSame(oldRetainedGraph, newRetainedGraph) - assertSame(newGraph, newRetainedGraph.graph) + assertSame(newGraph, newRetainedGraph) verify(exactly = 1) { oldImageLoader.shutdown() } } @Test fun givenSessionGraphIsStillValid_whenRequestedAgain_thenGraphIsReused() { val graph = graphWith(mockk(relaxed = true)) - val store = SessionGraphStoreViewModel { graph } + val store = SessionGraphStoreViewModel(createSessionGraph = { graph }) - val first = store.retainedGraphFor(USER_ID) - val second = store.retainedGraphFor(USER_ID) + val first = store.graphFor(USER_ID) + val second = store.graphFor(USER_ID) assertSame(first, second) } @Test - fun givenSameUserIsRemovedAndLogsInRepeatedly_whenGraphsAreInvalidated_thenEveryLoginGetsAFreshGraph() { + fun givenSameUserIsRemovedAndLogsInRepeatedly_whenLifecycleIsConfirmed_thenEveryLoginGetsAFreshGraph() { val imageLoaders = List(RELOGIN_COUNT) { mockk(relaxed = true) } val graphs = ArrayDeque(imageLoaders.map(::graphWith)) - val store = SessionGraphStoreViewModel { graphs.removeFirst() } + val store = SessionGraphStoreViewModel(createSessionGraph = { graphs.removeFirst() }) val retainedGraphs = buildList { repeat(RELOGIN_COUNT) { index -> - add(store.retainedGraphFor(USER_ID)) + add(store.graphFor(USER_ID)) if (index < RELOGIN_COUNT - 1) { - store.invalidate(USER_ID) + store.markInvalidating(USER_ID) + store.markRemoved(USER_ID) + store.markAvailable(USER_ID) } } } retainedGraphs.zipWithNext().forEach { (oldGraph, newGraph) -> assertNotSame(oldGraph, newGraph) - assertNotSame(oldGraph.graph, newGraph.graph) } imageLoaders.dropLast(1).forEach { imageLoader -> verify(exactly = 1) { imageLoader.shutdown() } @@ -112,21 +107,30 @@ class SessionGraphStoreViewModelTest { } @Test - fun givenTwoRetainedUsers_whenRemovedUserIsInvalidated_thenOtherUsersGraphRemainsActive() { + fun givenTwoRetainedUsers_whenOneIsRemoved_thenOtherUsersGraphRemainsActive() { val removedUserImageLoader = mockk(relaxed = true) val otherUserImageLoader = mockk(relaxed = true) - val graphsByUser = mapOf( - USER_ID to graphWith(removedUserImageLoader), - OTHER_USER_ID to graphWith(otherUserImageLoader), + val removedUserGraphs = ArrayDeque( + listOf( + graphWith(removedUserImageLoader), + graphWith(mockk(relaxed = true)), + ) ) - val store = SessionGraphStoreViewModel { userId -> graphsByUser.getValue(userId) } - val otherUsersGraph = store.retainedGraphFor(OTHER_USER_ID) - val removedUsersGraph = store.retainedGraphFor(USER_ID) + val otherUserGraph = graphWith(otherUserImageLoader) + val store = SessionGraphStoreViewModel( + createSessionGraph = { userId -> + if (userId == USER_ID) removedUserGraphs.removeFirst() else otherUserGraph + } + ) + val removedUsersGraph = store.graphFor(USER_ID) + val otherUsersGraph = store.graphFor(OTHER_USER_ID) - store.invalidateActive() + store.markInvalidating(USER_ID) + store.markRemoved(USER_ID) + store.markAvailable(USER_ID) - assertSame(otherUsersGraph, store.retainedGraphFor(OTHER_USER_ID)) - assertNotSame(removedUsersGraph, store.retainedGraphFor(USER_ID)) + assertSame(otherUsersGraph, store.graphFor(OTHER_USER_ID)) + assertNotSame(removedUsersGraph, store.graphFor(USER_ID)) verify(exactly = 1) { removedUserImageLoader.shutdown() } verify(exactly = 0) { otherUserImageLoader.shutdown() } } @@ -135,15 +139,6 @@ class SessionGraphStoreViewModelTest { every { wireSessionImageLoader } returns imageLoader } - private class TrackingViewModel : ViewModel() { - var wasCleared = false - private set - - override fun onCleared() { - wasCleared = true - } - } - private companion object { const val RELOGIN_COUNT = 3 val USER_ID = UserId("user", "domain") diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityActiveGraphResolverTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityActiveGraphResolverTest.kt deleted file mode 100644 index 4ab66892820..00000000000 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityActiveGraphResolverTest.kt +++ /dev/null @@ -1,431 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.ui - -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewWelcomeEmptyStartScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.RegisterDeviceScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination -import com.wire.android.di.metro.AppAuthenticationViewModelGraph -import com.wire.android.di.metro.AppSessionViewModelGraph -import com.wire.android.navigation.baseRoute -import com.wire.kalium.logic.data.user.UserId -import io.mockk.mockk -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertSame -import org.junit.jupiter.api.Test - -class WireActivityActiveGraphResolverTest { - - private val authenticationViewModelGraph = AppAuthenticationViewModelGraph() - private val sessionGraph = mockk() - - @Test - fun givenOldLoginRouteHasSessionGraph_whenResolvingActiveGraph_thenAuthenticationGraphIsUsed() { - val graph = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = sessionGraph, - effectiveBaseRoute = LoginScreenDestination.baseRoute, - currentBaseRoute = LoginScreenDestination.baseRoute, - usesAuthenticationGraph = true, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = false, - ) - ) - - assertSame(authenticationViewModelGraph, graph) - } - - @Test - fun givenSessionBackedAuthenticationRouteHasSessionGraph_whenResolvingActiveGraph_thenSessionGraphIsUsed() { - val graph = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = sessionGraph, - effectiveBaseRoute = RegisterDeviceScreenDestination.baseRoute, - currentBaseRoute = RegisterDeviceScreenDestination.baseRoute, - usesAuthenticationGraph = true, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = false, - ) - ) - - assertSame(sessionGraph, graph) - } - - @Test - fun givenCurrentRouteIsUnknownAndLoggedOutStartIsAuth_whenResolvingActiveGraph_thenAuthenticationGraphIsUsed() { - val graph = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = null, - effectiveBaseRoute = NewWelcomeEmptyStartScreenDestination.baseRoute, - currentBaseRoute = null, - usesAuthenticationGraph = true, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = false, - ) - ) - - assertSame(authenticationViewModelGraph, graph) - } - - @Test - fun givenCurrentRouteIsUnknownAndLoggedOutStartIsSessionRoute_whenResolvingActiveGraph_thenNoGraphIsUsed() { - val graph = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = null, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = null, - usesAuthenticationGraph = false, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = false, - ) - ) - - assertNull(graph) - } - - @Test - fun givenCurrentRouteIsUnknownAndSessionGraphExists_whenResolvingActiveGraph_thenSessionGraphIsUsed() { - val graph = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = sessionGraph, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = null, - usesAuthenticationGraph = false, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = false, - ) - ) - - assertSame(sessionGraph, graph) - } - - @Test - fun givenSessionTransitionIsInProgressOnSessionRoute_whenResolvingActiveGraph_thenNoGraphIsUsed() { - val graph = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = sessionGraph, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = HomeScreenDestination.baseRoute, - usesAuthenticationGraph = false, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = true, - ) - ) - - assertNull(graph) - } - - @Test - fun givenUserLogsOutFromSessionRoute_whenResolvingTransientStates_thenSessionGraphIsDroppedAndAuthRootIsUsed() { - val loggedInGraph = resolveActiveGraph( - sessionGraph = sessionGraph, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = HomeScreenDestination.baseRoute, - ) - val transitioningGraph = resolveActiveGraph( - sessionGraph = null, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = HomeScreenDestination.baseRoute, - isSessionTransitionInProgress = true, - ) - val loggedOutStartDestination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = null, - currentBaseRoute = null, - canUseNewLogin = true, - ) - val loggedOutGraph = resolveActiveGraph( - sessionGraph = null, - effectiveBaseRoute = loggedOutStartDestination.baseRoute, - currentBaseRoute = null, - usesAuthenticationGraph = true, - ) - - assertSame(sessionGraph, loggedInGraph) - assertNull(transitioningGraph) - assertEquals(NewWelcomeEmptyStartScreenDestination.baseRoute, loggedOutStartDestination.baseRoute) - assertSame(authenticationViewModelGraph, loggedOutGraph) - } - - @Test - fun givenUserSwitchesAccountsFromSessionRoute_whenResolvingTransientStates_thenGraphIsDroppedUntilNewSessionGraphExists() { - val newSessionGraph = mockk() - val currentUserId = UserId("current-user", "domain") - val switchedUserId = UserId("switched-user", "domain") - - val currentAccountGraph = resolveActiveGraph( - sessionGraph = sessionGraph, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = HomeScreenDestination.baseRoute, - ) - val switchingGraph = resolveActiveGraph( - sessionGraph = null, - effectiveBaseRoute = HomeScreenDestination.baseRoute, - currentBaseRoute = HomeScreenDestination.baseRoute, - isSessionTransitionInProgress = true, - ) - val switchedStartDestination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = switchedUserId, - currentBaseRoute = HomeScreenDestination.baseRoute, - canUseNewLogin = true, - ) - val switchedAccountGraph = resolveActiveGraph( - sessionGraph = newSessionGraph, - effectiveBaseRoute = switchedStartDestination.baseRoute, - currentBaseRoute = HomeScreenDestination.baseRoute, - ) - - assertEquals( - HomeScreenDestination.baseRoute, - resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = currentUserId, - currentBaseRoute = HomeScreenDestination.baseRoute, - canUseNewLogin = true, - ).baseRoute - ) - assertSame(sessionGraph, currentAccountGraph) - assertNull(switchingGraph) - assertEquals(HomeScreenDestination.baseRoute, switchedStartDestination.baseRoute) - assertSame(newSessionGraph, switchedAccountGraph) - } - - @Test - fun givenLoggedOutUserHasUnknownRouteAndNewLoginEnabled_whenResolvingNavHostStartDestination_thenEmptyWelcomeRootIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = null, - currentBaseRoute = null, - canUseNewLogin = true, - ) - - assertEquals(NewWelcomeEmptyStartScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedOutUserHasUnknownRouteAndNewLoginDisabled_whenResolvingNavHostStartDestination_thenWelcomeRootIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = null, - currentBaseRoute = null, - canUseNewLogin = false, - ) - - assertEquals(WelcomeScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedOutUserIsOnNewLoginRoute_whenResolvingNavHostStartDestination_thenEmptyWelcomeRootIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = null, - currentBaseRoute = NewLoginScreenDestination.baseRoute, - canUseNewLogin = true, - ) - - assertEquals(NewWelcomeEmptyStartScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedOutUserIsOnOldLoginRoute_whenResolvingNavHostStartDestination_thenWelcomeRootIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = null, - currentBaseRoute = LoginScreenDestination.baseRoute, - canUseNewLogin = false, - ) - - assertEquals(WelcomeScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedInUserHasUnknownRoute_whenResolvingNavHostStartDestination_thenInitialStartDestinationIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = UserId("user", "domain"), - currentBaseRoute = null, - canUseNewLogin = true, - ) - - assertEquals(HomeScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedInUserIsOnNestedNoSessionAuthRoute_whenResolvingNavHostStartDestination_thenInitialStartDestinationIsKept() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = UserId("user", "domain"), - currentBaseRoute = NewLoginPasswordScreenDestination.baseRoute, - canUseNewLogin = true, - ) - - assertEquals(HomeScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenNoSessionLoginFlowReceivesCurrentUserOnPasswordRoute_whenResolvingNavHostStartDestination_thenAuthRootIsKept() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = UserId("user", "domain"), - currentBaseRoute = NewLoginPasswordScreenDestination.baseRoute, - canUseNewLogin = true, - noSessionAuthenticationStartedWithoutSession = true, - ) - - assertEquals(NewWelcomeEmptyStartScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenAddAccountFlowReceivesCurrentUserOnPasswordRoute_whenResolvingNavHostStartDestination_thenSessionRootIsKept() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = UserId("user", "domain"), - currentBaseRoute = NewLoginPasswordScreenDestination.baseRoute, - canUseNewLogin = true, - noSessionAuthenticationStartedWithoutSession = false, - ) - - assertEquals(HomeScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedInUserIsOnNewLoginRoute_whenResolvingNavHostStartDestination_thenInitialStartDestinationIsKept() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = UserId("user", "domain"), - currentBaseRoute = NewLoginScreenDestination.baseRoute, - canUseNewLogin = true, - ) - - assertEquals(HomeScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenInitialStartDestinationIsAuthenticationRoot_whenResolvingNavHostStartDestination_thenInitialStartDestinationIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = NewWelcomeEmptyStartScreenDestination, - currentUserId = null, - currentBaseRoute = null, - canUseNewLogin = false, - ) - - assertEquals(NewWelcomeEmptyStartScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenLoggedOutUserIsOnNestedNoSessionAuthRoute_whenResolvingNavHostStartDestination_thenEmptyWelcomeRootIsUsed() { - val destination = resolveWireActivityNavHostStartDestination( - initialStartDestination = HomeScreenDestination, - currentUserId = null, - currentBaseRoute = NewLoginPasswordScreenDestination.baseRoute, - canUseNewLogin = true, - ) - - assertEquals(NewWelcomeEmptyStartScreenDestination.baseRoute, destination.baseRoute) - } - - @Test - fun givenActiveSessionGraphExists_whenResolvingImageLoaderSessionGraph_thenActiveSessionGraphIsUsed() { - val retainedSessionGraph = mockk() - - val imageLoaderSessionGraph = resolveWireActivityImageLoaderSessionGraph( - activeSessionGraph = sessionGraph, - retainedSessionGraph = retainedSessionGraph, - ) - - assertSame(sessionGraph, imageLoaderSessionGraph) - } - - @Test - fun givenAuthGraphIsActiveAndSessionContentIsRetained_whenResolvingImageLoaderSessionGraph_thenRetainedSessionGraphIsUsed() { - val imageLoaderSessionGraph = resolveWireActivityImageLoaderSessionGraph( - activeSessionGraph = null, - retainedSessionGraph = sessionGraph, - ) - - assertSame(sessionGraph, imageLoaderSessionGraph) - } - - @Test - fun givenSessionErrorBlocksTheUi_whenCheckingSessionGraphInvalidation_thenGraphIsInvalidated() { - val shouldInvalidate = shouldInvalidateWireActivitySessionGraph( - isUserUiBlocked = true, - sessionTransitionReason = null, - ) - - assertEquals(true, shouldInvalidate) - } - - @Test - fun givenSelfLogoutIsInProgress_whenCheckingSessionGraphInvalidation_thenGraphIsInvalidated() { - val shouldInvalidate = shouldInvalidateWireActivitySessionGraph( - isUserUiBlocked = false, - sessionTransitionReason = SessionTransitionReason.SELF_LOGOUT, - ) - - assertEquals(true, shouldInvalidate) - } - - @Test - fun givenSessionIsActive_whenCheckingSessionGraphInvalidation_thenGraphIsRetained() { - val shouldInvalidate = shouldInvalidateWireActivitySessionGraph( - isUserUiBlocked = false, - sessionTransitionReason = null, - ) - - assertEquals(false, shouldInvalidate) - } - - private fun resolveActiveGraph( - sessionGraph: AppSessionViewModelGraph?, - effectiveBaseRoute: String, - currentBaseRoute: String?, - usesAuthenticationGraph: Boolean = false, - isSessionTransitionInProgress: Boolean = false, - ) = resolveWireActivityActiveGraph( - WireActivityActiveGraphRequest( - authenticationViewModelGraph = authenticationViewModelGraph, - sessionGraph = sessionGraph, - effectiveBaseRoute = effectiveBaseRoute, - currentBaseRoute = currentBaseRoute, - usesAuthenticationGraph = usesAuthenticationGraph, - usesNoSessionAuthenticationGraph = false, - usesInvalidSessionBackedAuthenticationGraph = false, - isSessionTransitionInProgress = isSessionTransitionInProgress, - ) - ) -} diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsSourceTest.kt new file mode 100644 index 00000000000..2e16d0c6c22 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsSourceTest.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.ui + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityNavigation3EffectsSourceTest { + + @Test + fun givenNavigation3Effects_whenInspectingSource_thenNoLegacyNavigationApiLeaksIn() { + val source = source() + listOf( + "Navigator", + "NavController", + "ScreenDestination", + "SavedStateHandle", + "safeRoute", + "com.ramcosta.composedestinations", + ).forEach { forbidden -> + assertFalse(source.contains(forbidden), "Navigation 3 effects contain $forbidden") + } + assertTrue(source.contains("WireNavigation3Runtime")) + assertTrue(source.contains("current.copy(")) + assertTrue(source.contains("entryId = current.entryId")) + assertTrue(source.contains("flowId = current.flowId")) + } + + @Test + fun givenAllActivityActions_whenInspectingResolver_thenEveryActionIsHandled() { + val source = source() + listOf( + "OnAuthorizationNeeded", + "OnMigrationLogin", + "OnAutomaticLogin", + "OnCustomBackendLogin", + "OnOpenUserProfile", + "OnSSOLogin", + "OnShowImportMediaScreen", + "OpenConversation", + "OnUnknownDeepLink", + "ShowToast", + ).forEach { action -> + assertTrue(source.contains(action), "Missing typed handling for $action") + } + } + + private fun source(): String { + val root = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + root, + "app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Effects.kt", + ).readText() + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsTest.kt new file mode 100644 index 00000000000..62ebf4bb4db --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3EffectsTest.kt @@ -0,0 +1,244 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.ui + +import com.wire.android.navigation.routes.auth.AuthenticationPrefilledUserIdentifier +import com.wire.android.navigation.routes.auth.AuthenticationSsoLoginResult +import com.wire.android.navigation.routes.auth.LoginRoute +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute +import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.navigation.routes.media.AuthenticatedImportMediaRoute +import com.wire.android.navigation.routes.media.LoggedOutImportMediaRoute +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.home.conversations.ConversationRoute +import com.wire.android.ui.userprofile.other.OtherUserProfileRoute +import com.wire.android.util.deeplink.DeepLinkResult +import com.wire.android.util.deeplink.SSOFailureCodes +import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireActivityNavigation3EffectsTest { + private val session = WireSessionId("user", "wire.example") + + @Test + fun givenAuthorizationNeededOnEmptyWelcome_whenResolving_thenLoginClearsStackAndToastIsShown() { + val result = resolve(OnAuthorizationNeeded, listOf(NewWelcomeEmptyStartRoute())) + val command = result.singleCommand() + + assertInstanceOf(NewLoginRoute::class.java, command.destination) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + assertEquals(com.wire.android.R.string.deeplink_authorization_needed, result.toastMessageResId) + } + + @Test + fun givenMigrationLogin_whenNewLoginIsEnabled_thenHandleAndReplacementModeAreTyped() { + val result = resolve( + OnMigrationLogin(DeepLinkResult.MigrationLogin("alice")), + listOf(WelcomeRoute()), + ) + val command = result.singleCommand() + val route = assertInstanceOf(NewLoginRoute::class.java, command.destination) + + assertEquals( + AuthenticationPrefilledUserIdentifier("alice"), + route.args.userHandle, + ) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + } + + @Test + fun givenAutomaticLegacyLogin_whenResolving_thenSsoAndUpdateModeArePreserved() { + val result = resolve( + OnAutomaticLogin( + serverLinks = null, + ssoCode = "code", + nomadServiceUrl = "https://nomad.example", + cookieLabel = "cookie", + useNewLogin = false, + ), + listOf(HomeRoute(session)), + ) + val command = result.singleCommand() + val route = assertInstanceOf(LoginRoute::class.java, command.destination) + + assertEquals("code", route.args.ssoCodeAutoLogin?.ssoCode) + assertTrue(route.args.ssoCodeAutoLogin?.autoInitiateLogin == true) + assertEquals(WireBackStackMode.UPDATE_EXISTING, command.backStackMode) + } + + @Test + fun givenCustomBackendNewLogin_whenResolving_thenConfigAndSuccessFlagArePreserved() { + val result = resolve( + OnCustomBackendLogin(serverLinks(), useNewLogin = true), + listOf(HomeRoute(session)), + ) + val route = assertInstanceOf( + NewLoginRoute::class.java, + result.singleCommand().destination, + ) + + assertEquals("https://api.example", route.args.loginPasswordPath?.customServerConfig?.api) + assertTrue(route.args.showBackendConfigSuccess) + } + + @Test + fun givenCustomBackendResultOnLoginRoot_whenResolving_thenEntryAndFlowArePreserved() { + val current = NewLoginRoute( + flowId = "custom-backend:owner", + entryId = WireNavEntryId("custom-backend-entry"), + ) + + val result = resolve( + OnCustomBackendLogin(serverLinks(), useNewLogin = true), + listOf(current), + ) + val replacement = assertInstanceOf( + WireActivityNavigation3Mutation.ReplaceCurrent::class.java, + result.mutations.single(), + ).route as NewLoginRoute + + assertEquals(current.entryId, replacement.entryId) + assertEquals(current.flowId, replacement.flowId) + assertEquals("https://api.example", replacement.args.loginPasswordPath?.customServerConfig?.api) + assertTrue(replacement.args.showBackendConfigSuccess) + } + + @Test + fun givenSsoResultOnNewLogin_whenResolving_thenCurrentEntryAndFlowOwnersArePreserved() { + val current = NewLoginRoute( + flowId = "new-login:owner", + entryId = WireNavEntryId("owner"), + ) + val result = resolve( + OnSSOLogin(DeepLinkResult.SSOLogin.Success("cookie", "server")), + listOf(current), + ) + val replacement = assertInstanceOf( + WireActivityNavigation3Mutation.ReplaceCurrent::class.java, + result.mutations.single(), + ).route as NewLoginRoute + + assertEquals(current.entryId, replacement.entryId) + assertEquals(current.flowId, replacement.flowId) + assertEquals( + AuthenticationSsoLoginResult.Success("cookie", "server"), + replacement.args.ssoLoginResult, + ) + } + + @Test + fun givenConversationAfterAccountSwitch_whenResolving_thenHomeIsClearedBeforeTypedConversationUpdate() { + val targetSession = QualifiedID("switched-user", "wire.example") + val result = resolve( + OpenConversation( + DeepLinkResult.OpenConversation( + conversationId = QualifiedID("conversation", "wire.example"), + switchedAccount = true, + targetSessionId = targetSession, + ) + ), + listOf(HomeRoute(session)), + ) + val commands = result.mutations.map { + (it as WireActivityNavigation3Mutation.Navigate).command + } + + val home = assertInstanceOf(HomeRoute::class.java, commands[0].destination) + assertEquals(WireSessionId(targetSession.value, targetSession.domain), home.sessionId) + assertEquals(WireBackStackMode.CLEAR_WHOLE, commands[0].backStackMode) + val conversation = assertInstanceOf(ConversationRoute::class.java, commands[1].destination) + assertEquals(WireSessionId(targetSession.value, targetSession.domain), conversation.sessionId) + assertEquals(WireBackStackMode.UPDATE_EXISTING, commands[1].backStackMode) + } + + @Test + fun givenProfileAfterAccountSwitch_whenResolving_thenTypedProfileUsesCurrentSession() { + val targetSession = QualifiedID("switched-user", "wire.example") + val result = resolve( + OnOpenUserProfile( + DeepLinkResult.OpenOtherUserProfile( + userId = QualifiedID("other", "wire.example"), + switchedAccount = true, + targetSessionId = targetSession, + ) + ), + listOf(HomeRoute(session)), + ) + val commands = result.mutations.map { + (it as WireActivityNavigation3Mutation.Navigate).command + } + + assertInstanceOf(HomeRoute::class.java, commands[0].destination) + val profile = assertInstanceOf(OtherUserProfileRoute::class.java, commands[1].destination) + assertEquals(WireSessionId(targetSession.value, targetSession.domain), profile.sessionId) + assertEquals("other", profile.targetUserId.value) + } + + @Test + fun givenImportMediaWithAndWithoutSession_whenResolving_thenScopeOwnerIsUnambiguous() { + val authenticated = resolve(OnShowImportMediaScreen, listOf(HomeRoute(session))) + val loggedOut = WireActivityNavigation3EffectResolver.resolve( + OnShowImportMediaScreen, + routes = listOf(NewWelcomeEmptyStartRoute()), + currentSessionId = null, + canUseNewLogin = true, + ) + + assertInstanceOf(AuthenticatedImportMediaRoute::class.java, authenticated.singleCommand().destination) + assertInstanceOf(LoggedOutImportMediaRoute::class.java, loggedOut.singleCommand().destination) + } + + @Test + fun givenSsoFailureOutsideNewLogin_whenResolving_thenLegacyLoginIsUpdated() { + val result = resolve( + OnSSOLogin(DeepLinkResult.SSOLogin.Failure(SSOFailureCodes.Forbidden)), + listOf(HomeRoute(session)), + ) + val command = result.singleCommand() + val route = assertInstanceOf(LoginRoute::class.java, command.destination) + + assertEquals( + AuthenticationSsoLoginResult.Failure( + com.wire.android.navigation.routes.auth.AuthenticationSsoFailureCode.FORBIDDEN + ), + route.args.ssoLoginResult, + ) + assertEquals(WireBackStackMode.UPDATE_EXISTING, command.backStackMode) + } + + private fun resolve( + action: WireActivityViewAction, + routes: List, + ) = WireActivityNavigation3EffectResolver.resolve( + action = action, + routes = routes, + currentSessionId = session, + canUseNewLogin = true, + ) + + private fun WireActivityNavigation3EffectResolution.singleCommand() = + (mutations.single() as WireActivityNavigation3Mutation.Navigate).command + + private fun serverLinks() = ServerConfig.Links( + api = "https://api.example", + accounts = "https://accounts.example", + webSocket = "wss://socket.example", + blackList = "https://blacklist.example", + teams = "https://teams.example", + website = "https://wire.example", + title = "Example", + isOnPremises = true, + apiProxy = null, + ) +} diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffectsTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffectsTest.kt new file mode 100644 index 00000000000..fa80ab31f0b --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SessionEffectsTest.kt @@ -0,0 +1,154 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui + +import com.wire.android.navigation.routes.auth.NewLoginRoute +import com.wire.android.navigation.runtime.WireSessionNavigationSnapshot +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class WireActivityNavigation3SessionEffectsTest { + + @Test + fun givenSessionOnEmptyWelcome_whenResolving_thenClearsStackToTypedHome() { + val sessionId = WireSessionId("user", "example.com") + + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = true, + isEmptyWelcomeRoute = true, + isAuthenticationRoute = true, + ), + currentSessionId = sessionId, + ) + + val command = assertInstanceOf(WireActivityNavigation3SessionEffect.Navigate::class.java, effect).command + assertEquals(sessionId, assertInstanceOf(HomeRoute::class.java, command.destination).sessionId) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + } + + @Test + fun givenNoSessionOnEmptyWelcome_whenResolving_thenClearsStackToTypedLogin() { + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = false, + isEmptyWelcomeRoute = true, + isAuthenticationRoute = true, + ), + currentSessionId = null, + ) + + val command = assertInstanceOf(WireActivityNavigation3SessionEffect.Navigate::class.java, effect).command + assertInstanceOf(NewLoginRoute::class.java, command.destination) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + } + + @Test + fun givenNoSessionOnSessionBackedAuthentication_whenResolving_thenClearsStackToTypedLogin() { + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = false, + isAuthenticationRoute = true, + isSessionBackedAuthenticationRoute = true, + ), + currentSessionId = null, + ) + + val command = assertInstanceOf(WireActivityNavigation3SessionEffect.Navigate::class.java, effect).command + assertInstanceOf(NewLoginRoute::class.java, command.destination) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + } + + @Test + fun givenMissingSessionOnSessionRoute_whenResolving_thenRequestsAccountRecovery() { + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = false, + isAuthenticationRoute = false, + ), + currentSessionId = null, + ) + + assertEquals(WireActivityNavigation3SessionEffect.ResolveCurrentSession, effect) + } + + @Test + fun givenSelectedAccountChangedBehindCurrentRoute_whenResolving_thenClearsStackToNewHome() { + val previousSession = WireSessionId("previous", "example.com") + val currentSession = WireSessionId("current", "example.com") + + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = true, + isAuthenticationRoute = false, + ), + currentSessionId = currentSession, + routeSessionId = previousSession, + ) + + val command = assertInstanceOf(WireActivityNavigation3SessionEffect.Navigate::class.java, effect).command + assertEquals(currentSession, assertInstanceOf(HomeRoute::class.java, command.destination).sessionId) + assertEquals(WireBackStackMode.CLEAR_WHOLE, command.backStackMode) + } + + @Test + fun givenBlockedUiAndSelectedAccountChanged_whenResolving_thenWaitForDialogTakesPrecedence() { + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = true, + isAuthenticationRoute = false, + isUserUiBlocked = true, + ), + currentSessionId = WireSessionId("current", "example.com"), + routeSessionId = WireSessionId("previous", "example.com"), + ) + + assertEquals(WireActivityNavigation3SessionEffect.WaitForBlockingDialog, effect) + } + + @Test + fun givenAuthenticationRouteDuringSessionTransition_whenResolving_thenFinishesTransitionBeforeOtherEffects() { + val effect = WireActivityNavigation3SessionEffectResolver.resolve( + snapshot = snapshot( + hasCurrentSession = false, + isAuthenticationRoute = true, + isUserUiBlocked = true, + isSessionTransitionInProgress = true, + ), + currentSessionId = null, + ) + + assertEquals(WireActivityNavigation3SessionEffect.FinishTransition, effect) + } + + @Suppress("LongParameterList") + private fun snapshot( + hasCurrentSession: Boolean, + isEmptyWelcomeRoute: Boolean = false, + isAuthenticationRoute: Boolean, + isUserUiBlocked: Boolean = false, + isSessionBackedAuthenticationRoute: Boolean = false, + isSessionTransitionInProgress: Boolean = false, + ) = WireSessionNavigationSnapshot( + hasCurrentSession = hasCurrentSession, + hasCurrentRoute = true, + isEmptyWelcomeRoute = isEmptyWelcomeRoute, + isAuthenticationRoute = isAuthenticationRoute, + isSessionBackedAuthenticationRoute = isSessionBackedAuthenticationRoute, + isUserUiBlocked = isUserUiBlocked, + isSessionTransitionInProgress = isSessionTransitionInProgress, + isSelfLogoutTransition = false, + ) +} diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SetupTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SetupTest.kt new file mode 100644 index 00000000000..1406c33e288 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityNavigation3SetupTest.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui + +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.util.SwitchAccountObserver +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class WireActivityNavigation3SetupTest { + + @Test + fun givenRegisteredSwitchActions_whenObserverEmits_thenActionsStopReceivingEventsAfterUnregister() { + val observer = SwitchAccountObserver() + val received = mutableListOf() + val actions = object : SwitchAccountActions { + override fun switchedToAnotherAccount() { + received += "switched" + } + + override fun noOtherAccountToSwitch() { + received += "none" + } + } + val unregister = registerWireActivityNavigation3SwitchAccountActions(observer, actions) + + observer.switchedToAnotherAccount() + observer.noOtherAccountToSwitch() + unregister() + observer.switchedToAnotherAccount() + observer.noOtherAccountToSwitch() + + assertEquals(listOf("switched", "none"), received) + } + + @Test + fun givenNavigation3SetupSource_whenInspectingEffects_thenLifecycleAndTypedBoundariesAreExplicit() { + val source = navigation3SetupSource().readText() + + assertTrue(source.contains("ObserveNavigation3Routes(")) + assertTrue(source.contains("currentKeyboardController?.hide()")) + assertTrue(source.contains("repeatOnLifecycle(Lifecycle.State.STARTED)")) + assertTrue(source.contains("currentIntentHandler(request)")) + assertTrue(source.contains("repeatOnLifecycle(Lifecycle.State.RESUMED)")) + assertTrue(source.contains("registerWireActivityNavigation3SwitchAccountActions(")) + assertTrue(source.contains("onDispose(unregister)")) + } + + @Test + fun givenNavigation3SetupSource_whenInspectingImports_thenNoLegacyNavigationApiIsReferenced() { + val source = navigation3SetupSource().readText() + + listOf( + "com.ramcosta.composedestinations", + "androidx.navigation.NavController", + "com.ramcosta.composedestinations.generated", + "Navigator", + ).forEach { forbidden -> + assertFalse(source.contains(forbidden), "Navigation 3 setup must not reference $forbidden") + } + } + + private fun navigation3SetupSource(): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + projectDir, + "app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Setup.kt", + ).also { + assertTrue(it.isFile, "Missing Navigation 3 setup source") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivitySessionLifecycleTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivitySessionLifecycleTest.kt new file mode 100644 index 00000000000..ba51d010c55 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivitySessionLifecycleTest.kt @@ -0,0 +1,75 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui + +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireSessionId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WireActivitySessionLifecycleTest { + + @Test + fun givenSessionErrorBlocksTheUi_whenCheckingSessionGraphInvalidation_thenGraphIsInvalidated() { + assertEquals( + true, + shouldInvalidateWireActivitySessionGraph( + isUserUiBlocked = true, + sessionTransitionReason = null, + ), + ) + } + + @Test + fun givenSelfLogoutIsInProgress_whenCheckingSessionGraphInvalidation_thenGraphIsInvalidated() { + assertEquals( + true, + shouldInvalidateWireActivitySessionGraph( + isUserUiBlocked = false, + sessionTransitionReason = SessionTransitionReason.SELF_LOGOUT, + ), + ) + } + + @Test + fun givenSessionIsActive_whenCheckingSessionGraphInvalidation_thenGraphIsRetained() { + assertEquals( + false, + shouldInvalidateWireActivitySessionGraph( + isUserUiBlocked = false, + sessionTransitionReason = null, + ), + ) + } + + @Test + fun givenTypedSession_whenTearingDown_thenOwnerAndGraphAreClearedInOrderForThatSession() { + val sessionId = WireSessionId("route-user", "wire.test") + val expectedUserId = UserId("route-user", "wire.test") + val events = mutableListOf() + + teardownWireActivitySession( + sessionId = sessionId, + markInvalidating = { events += "invalidating:$it" }, + clearOwner = { events += "clear:$it" }, + markRemoved = { events += "removed:$it" }, + ) + + assertEquals( + listOf( + "invalidating:$expectedUserId", + "clear:session:route-user@wire.test", + "removed:$expectedUserId", + ), + events, + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index ff99cd315b5..984a797c9a1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -42,6 +42,7 @@ import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountParam +import com.wire.android.feature.SwitchAccountActions import com.wire.android.feature.SwitchAccountResult import com.wire.android.framework.TestClient import com.wire.android.framework.TestUser @@ -876,8 +877,64 @@ class WireActivityViewModelTest { viewModel.globalAppState.blockUserUI shouldBeEqualTo CurrentSessionErrorState.RemovedClient } + @Test + fun givenInvalidAccount_whenCurrentAccountObserverRuns_thenKeepSessionUntilLogoutStateIsPublished() { + val initialState = GlobalAppState(currentUserId = USER_ID) + + val result = initialState.withObservedCurrentAccount( + invalidAccountInfo(LogoutReason.REMOVED_CLIENT) + ) + + result shouldBeEqualTo initialState + } + + @Test + fun givenSameAlreadyValidAccount_whenObservedAgain_thenDoNotConfirmANewSessionGeneration() { + val initialState = GlobalAppState( + currentUserId = USER_ID, + confirmedSessionGeneration = 7, + ) + + val result = initialState.withObservedCurrentAccount(TEST_ACCOUNT_INFO) + + result.confirmedSessionGeneration shouldBeEqualTo 7 + } + + @Test + fun givenSessionWasInvalidated_whenValidAccountIsObserved_thenConfirmANewSessionGeneration() { + val initialState = GlobalAppState( + currentUserId = null, + confirmedSessionGeneration = 7, + blockUserUI = CurrentSessionErrorState.RemovedClient, + sessionTransitionReason = SessionTransitionReason.REMOVED_CLIENT, + ) + + val result = initialState.withObservedCurrentAccount(TEST_ACCOUNT_INFO) + + result.currentUserId shouldBeEqualTo USER_ID + result.confirmedSessionGeneration shouldBeEqualTo 8 + } + + @Test + fun givenRemovedClient_whenRecoveringLogin_thenPreserveAndConsumeItsServerConfig() = runTest { + val serverConfig = newServerConfig(42) + val (_, viewModel) = Arrangement() + .withInvalidCurrentSession(logoutReason = LogoutReason.REMOVED_CLIENT) + .withServerConfigForUser(USER_ID, serverConfig) + .arrange() + + advanceUntilIdle() + + viewModel.globalAppState.pendingRemovedClientRecovery shouldBeEqualTo + PendingRemovedClientRecovery(USER_ID, serverConfig.links) + viewModel.consumeSessionRecoveryServerLinks() shouldBeEqualTo serverConfig.links + viewModel.globalAppState.pendingRemovedClientRecovery shouldBeEqualTo null + } + @Test fun givenNoOtherAccount_whenTryingToSwitchFromLoggedOutDialog_thenExposeLoggedOutState() = runTest { + var navigatedToLogin = false + val actions = switchAccountActions(onNoOtherAccount = { navigatedToLogin = true }) val (arrangement, viewModel) = Arrangement() .withInvalidCurrentSession(logoutReason = LogoutReason.REMOVED_CLIENT) .withAccountSwitchResult(SwitchAccountResult.NoOtherAccountToSwitch) @@ -885,16 +942,20 @@ class WireActivityViewModelTest { advanceUntilIdle() viewModel.globalAppState.blockUserUI shouldBeEqualTo CurrentSessionErrorState.RemovedClient - viewModel.tryToSwitchAccount() + viewModel.tryToSwitchAccount(actions) advanceUntilIdle() arrangement.verifyTryToSwitchToNextAccount() viewModel.globalAppState.currentUserId shouldBeEqualTo null viewModel.globalAppState.blockUserUI shouldBeEqualTo null + viewModel.globalAppState.isSessionTransitionInProgress shouldBeEqualTo true + navigatedToLogin shouldBeEqualTo true } @Test fun givenAnotherAccount_whenTryingToSwitchFromLoggedOutDialog_thenExposeSwitchedAccountState() = runTest { + var navigatedToHome = false + val actions = switchAccountActions(onSwitchedAccount = { navigatedToHome = true }) val (arrangement, viewModel) = Arrangement() .withInvalidCurrentSession(logoutReason = LogoutReason.REMOVED_CLIENT) .withCurrentSession(CurrentSessionResult.Success(TEST_ACCOUNT_INFO)) @@ -903,12 +964,23 @@ class WireActivityViewModelTest { advanceUntilIdle() viewModel.globalAppState.blockUserUI shouldBeEqualTo CurrentSessionErrorState.RemovedClient - viewModel.tryToSwitchAccount() + viewModel.tryToSwitchAccount(actions) advanceUntilIdle() arrangement.verifyTryToSwitchToNextAccount() viewModel.globalAppState.currentUserId shouldBeEqualTo TEST_ACCOUNT_INFO.userId viewModel.globalAppState.blockUserUI shouldBeEqualTo null + viewModel.globalAppState.isSessionTransitionInProgress shouldBeEqualTo false + navigatedToHome shouldBeEqualTo true + } + + private fun switchAccountActions( + onSwitchedAccount: () -> Unit = {}, + onNoOtherAccount: () -> Unit = {}, + ): SwitchAccountActions = object : SwitchAccountActions { + override fun switchedToAnotherAccount() = onSwitchedAccount() + + override fun noOtherAccountToSwitch() = onNoOtherAccount() } @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelOwnershipSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelOwnershipSourceTest.kt new file mode 100644 index 00000000000..33fde549a44 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelOwnershipSourceTest.kt @@ -0,0 +1,58 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.authentication + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AuthenticationViewModelOwnershipSourceTest { + + @Test + fun givenAuthenticationViewModels_whenInspectingCreation_thenOnlyWireMetroGatewayDefinesIdentity() { + val source = authenticationViewModelGraph().readText() + + assertTrue(source.contains("wireMetroViewModel")) + assertTrue(source.contains("wireAssistedMetroViewModel")) + listOf( + "viewModelStoreOwner.hashCode()", + "LocalWireViewModelScopeKey", + "sessionKeyedMetroViewModelKey", + "assistedMetroViewModel<", + "metroxViewModel(", + "navArgs.toString()", + "loginNavArgs.toString()", + ).forEach { forbidden -> + assertFalse(source.contains(forbidden), "Authentication VM identity contains $forbidden") + } + } + + private fun authenticationViewModelGraph(): File { + val userDir = requireNotNull(System.getProperty("user.dir")) + val root = generateSequence(File(userDir)) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + root, + "app/src/main/kotlin/com/wire/android/ui/authentication/AuthenticationViewModelGraph.kt", + ).also { + assertTrue(it.isFile, "Missing ${it.path}") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModelTest.kt index 863fd196207..2743b1149cf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsViewModelTest.kt @@ -19,21 +19,17 @@ package com.wire.android.ui.authentication.create.details import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.assertions.shouldBeInstanceOf import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -42,7 +38,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class CreateAccountDetailsViewModelTest { @Test @@ -92,22 +88,21 @@ class CreateAccountDetailsViewModelTest { } private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var validatePasswordUseCase: ValidatePasswordUseCase init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns - CreateAccountNavArgs(CreateAccountFlowType.CreatePersonalAccount) } fun withValidatePasswordResult(result: ValidatePasswordResult) = apply { coEvery { validatePasswordUseCase(any()) } returns result } - fun arrange() = this to CreateAccountDetailsViewModel(savedStateHandle, validatePasswordUseCase, ServerConfig.STAGING) + fun arrange() = this to CreateAccountDetailsViewModel( + CreateAccountNavArgs(CreateAccountFlowType.CreatePersonalAccount), + validatePasswordUseCase, + ServerConfig.STAGING, + ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModelTest.kt index 61e962a9890..9cfaf0fd1f8 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModelTest.kt @@ -18,15 +18,12 @@ package com.wire.android.ui.authentication.create.email -import androidx.lifecycle.SavedStateHandle import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.assertions.shouldBeInstanceOf import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.ui.authentication.create.common.CreateAccountFlowType import com.wire.android.ui.authentication.create.common.CreateAccountNavArgs -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.AuthenticationScope @@ -36,7 +33,6 @@ import com.wire.kalium.logic.feature.register.RequestActivationCodeResult import com.wire.kalium.logic.feature.register.RequestActivationCodeUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -45,7 +41,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class CreateAccountEmailViewModelTest { @Test @@ -75,9 +71,6 @@ class CreateAccountEmailViewModelTest { } private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var validateEmailUseCase: ValidateEmailUseCase @@ -95,8 +88,6 @@ class CreateAccountEmailViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns - CreateAccountNavArgs(CreateAccountFlowType.CreatePersonalAccount) coEvery { coreLogic.versionedAuthenticationScope(any()) } returns autoVersionAuthScopeUseCase coEvery { autoVersionAuthScopeUseCase(any()) } returns AutoVersionAuthScopeUseCase.Result.Success(versionedAuthenticationScope) @@ -107,6 +98,11 @@ class CreateAccountEmailViewModelTest { coEvery { requestActivationCodeUseCase(any()) } returns result } - fun arrange() = this to CreateAccountEmailViewModel(savedStateHandle, validateEmailUseCase, coreLogic, ServerConfig.STAGING) + fun arrange() = this to CreateAccountEmailViewModel( + CreateAccountNavArgs(CreateAccountFlowType.CreatePersonalAccount), + validateEmailUseCase, + coreLogic, + ServerConfig.STAGING, + ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModelTest.kt index f77452c0297..81a42b3fa72 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/devices/common/ClearSessionViewModelTest.kt @@ -32,8 +32,10 @@ import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.session.DeleteSessionUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerifyOrder import io.mockk.coVerify import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import com.wire.android.assertions.shouldBeEqualTo @@ -101,6 +103,52 @@ class ClearSessionViewModelTest { arrangement.switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) arrangement.switchAccountActions.switchedToAnotherAccount() } + coVerifyOrder { + arrangement.switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) + arrangement.switchAccountActions.switchedToAnotherAccount() + arrangement.logout(LogoutReason.SELF_HARD_LOGOUT, true) + arrangement.deleteSession(currentSession.userId) + } + } + + @Test + fun `given account switch fails, when canceling login, then still logout and delete the unfinished session`() = runTest { + val currentSession = AccountInfo.Valid(UserId("userId", "domain")) + val (arrangement, viewModel) = Arrangement() + .withCurrentSessionReturning(CurrentSessionResult.Success(currentSession)) + .withSwitchAccountThrowing(IllegalStateException("switch failed")) + .arrange() + + val failure = runCatching { + viewModel.cancelLogin(arrangement.switchAccountActions) + }.exceptionOrNull() + + failure?.message shouldBeEqualTo "switch failed" + coVerifyOrder { + arrangement.switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) + arrangement.logout(LogoutReason.SELF_HARD_LOGOUT, true) + arrangement.deleteSession(currentSession.userId) + } + } + + @Test + fun `given account switch is canceled, when canceling login, then cleanup remains non-cancellable`() = runTest { + val currentSession = AccountInfo.Valid(UserId("userId", "domain")) + val (arrangement, viewModel) = Arrangement() + .withCurrentSessionReturning(CurrentSessionResult.Success(currentSession)) + .withSwitchAccountThrowing(CancellationException("switch canceled")) + .arrange() + + val failure = runCatching { + viewModel.cancelLogin(arrangement.switchAccountActions) + }.exceptionOrNull() + + failure?.message shouldBeEqualTo "switch canceled" + coVerifyOrder { + arrangement.switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) + arrangement.logout(LogoutReason.SELF_HARD_LOGOUT, true) + arrangement.deleteSession(currentSession.userId) + } } @Test @@ -185,5 +233,9 @@ class ClearSessionViewModelTest { fun withSwitchAccountReturning(result: SwitchAccountResult) = apply { coEvery { switchAccount(any()) } returns result } + + fun withSwitchAccountThrowing(failure: Exception) = apply { + coEvery { switchAccount(any()) } throws failure + } } } diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt index 2bcc5d0c6f8..8c5c21179cf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt @@ -27,7 +27,6 @@ import com.wire.android.assertions.shouldBeInstanceOf import com.wire.android.assertions.shouldNotBeEqualTo import com.wire.android.assertions.shouldNotBeInstanceOf import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -82,6 +81,7 @@ import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -92,7 +92,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) +@Suppress("LargeClass") class LoginEmailViewModelTest { private val dispatcherProvider = TestDispatcherProvider(StandardTestDispatcher()) @@ -262,6 +263,10 @@ class LoginEmailViewModelTest { advanceUntilIdle() loginViewModel.loginState.flowState.shouldBeInstanceOf() + coVerify(exactly = 0) { + arrangement.logoutUseCase(any(), any()) + arrangement.deleteSessionUseCase(any()) + } } @Test @@ -729,6 +734,67 @@ class LoginEmailViewModelTest { } } + @Test + fun `given second account requires device removal, when credentials emit again, then preserve navigation state`() = runTest { + val previousUserId = UserId("previousUserId", "domain") + val newUserId = UserId("newUserId", "domain") + val authToken = AUTH_TOKEN.copy(userId = newUserId) + val (arrangement, viewModel) = Arrangement() + .withDeleteSessionReturning(DeleteSessionUseCase.Result.Success) + .withUpdateCurrentSessionReturning(UpdateCurrentSessionUseCase.Result.Success) + .withCurrentSessionReturning(CurrentSessionResult.Success(AccountInfo.Valid(previousUserId))) + .withLoginReturning(AuthenticationResult.Success(authToken, SSO_ID, MANAGED_BY, SERVER_CONFIG.id, null)) + .withAddAuthenticatedUserReturning(AddAuthenticatedUserUseCase.Result.Success(newUserId)) + .withValidateEmailReturning(true) + .withPersistEmailReturning(PersistSelfUserEmailResult.Success) + .withGetOrRegisterClientReturning(RegisterClientResult.Failure.TooManyClients) + .arrange() + viewModel.userIdentifierTextState.setTextAndPlaceCursorAtEnd("second.account@example.org") + viewModel.passwordTextState.setTextAndPlaceCursorAtEnd("password") + advanceUntilIdle() + + viewModel.login() + advanceUntilIdle() + viewModel.loginState.flowState shouldBeEqualTo LoginState.Error.TooManyDevicesError(newUserId) + + viewModel.passwordTextState.setTextAndPlaceCursorAtEnd("password-updated-by-input") + advanceUntilIdle() + + viewModel.loginState.flowState shouldBeEqualTo LoginState.Error.TooManyDevicesError(newUserId) + coVerify(exactly = 0) { + arrangement.deleteSessionUseCase(newUserId) + arrangement.updateCurrentSessionUseCase(previousUserId) + } + } + + @Test + fun `given new session was stored, when login flow is canceled, then revert session before another attempt`() = runTest { + val previousUserId = UserId("previousUserId", "domain") + val newUserId = UserId("newUserId", "domain") + val authToken = AUTH_TOKEN.copy(userId = newUserId) + val (arrangement, viewModel) = Arrangement() + .withDeleteSessionReturning(DeleteSessionUseCase.Result.Success) + .withUpdateCurrentSessionReturning(UpdateCurrentSessionUseCase.Result.Success) + .withCurrentSessionReturning(CurrentSessionResult.Success(AccountInfo.Valid(previousUserId))) + .withLoginReturning(AuthenticationResult.Success(authToken, SSO_ID, MANAGED_BY, SERVER_CONFIG.id, null)) + .withAddAuthenticatedUserReturning(AddAuthenticatedUserUseCase.Result.Success(newUserId)) + .withValidateEmailReturning(true) + .withPersistEmailReturning(PersistSelfUserEmailResult.Success) + .arrange() + coEvery { arrangement.getOrRegisterClientUseCase(any()) } coAnswers { awaitCancellation() } + + viewModel.login() + advanceUntilIdle() + viewModel.loginJobData.value?.job?.cancel() + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.logoutUseCase(LogoutReason.SELF_HARD_LOGOUT, true) + arrangement.deleteSessionUseCase(newUserId) + arrangement.updateCurrentSessionUseCase(previousUserId) + } + } + @Test fun `given other failure than too many clients, when registering client, then revert new session`() = runTest { // given diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt index f1c121ed9b4..a629b1ef544 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt @@ -24,7 +24,6 @@ import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.assertions.shouldBeInstanceOf import com.wire.android.assertions.shouldNotBeInstanceOf import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.UserDataStoreProvider @@ -88,7 +87,7 @@ import android.database.sqlite.SQLiteException import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) @Suppress("LargeClass") class LoginSSOViewModelTest { diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModelTest.kt index 0ca7fdfa1ed..6e55415d420 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/welcome/WelcomeViewModelTest.kt @@ -18,11 +18,8 @@ package com.wire.android.ui.authentication.welcome -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.mockUri -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.newServerConfig import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.session.DoesValidNomadAccountExistUseCase @@ -30,7 +27,6 @@ import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.session.GetSessionsUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -41,12 +37,9 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class WelcomeViewModelTest { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getSessions: GetSessionsUseCase @@ -54,20 +47,21 @@ class WelcomeViewModelTest { lateinit var doesValidNomadAccountExist: DoesValidNomadAccountExistUseCase private lateinit var welcomeViewModel: WelcomeViewModel + private lateinit var welcomeNavArgs: WelcomeNavArgs @BeforeEach fun setUp() { MockKAnnotations.init(this, relaxUnitFun = true) mockUri() val authServer = newServerConfig(1) - every { savedStateHandle.navArgs() } returns WelcomeNavArgs(authServer.links) + welcomeNavArgs = WelcomeNavArgs(authServer.links) coEvery { getSessions() } returns GetAllSessionsResult.Success(listOf()) coEvery { doesValidNomadAccountExist() } returns false } @Test fun `given no nomad account exists, when checking sessions, then nomadAccountBlocksLogin is false`() = runTest { - welcomeViewModel = WelcomeViewModel(savedStateHandle, getSessions, doesValidNomadAccountExist, ServerConfig.STAGING) + welcomeViewModel = WelcomeViewModel(welcomeNavArgs, getSessions, doesValidNomadAccountExist, ServerConfig.STAGING) advanceUntilIdle() assertEquals(false, welcomeViewModel.state.nomadAccountBlocksLogin) @@ -77,7 +71,7 @@ class WelcomeViewModelTest { fun `given nomad account exists, when checking sessions, then nomadAccountBlocksLogin is true`() = runTest { coEvery { doesValidNomadAccountExist() } returns true - welcomeViewModel = WelcomeViewModel(savedStateHandle, getSessions, doesValidNomadAccountExist, ServerConfig.STAGING) + welcomeViewModel = WelcomeViewModel(welcomeNavArgs, getSessions, doesValidNomadAccountExist, ServerConfig.STAGING) advanceUntilIdle() assertEquals(true, welcomeViewModel.state.nomadAccountBlocksLogin) diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/CallActivityRoutingTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/CallActivityRoutingTest.kt new file mode 100644 index 00000000000..745d22738fb --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/calling/CallActivityRoutingTest.kt @@ -0,0 +1,75 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.calling + +import android.app.Application +import android.content.Intent +import com.wire.kalium.logic.data.id.QualifiedIdMapper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class CallActivityRoutingTest { + + private val mapper = QualifiedIdMapper(null) + + @Test + fun givenValidStartingIntent_whenParsed_thenAllTypedArgumentsAreReturned() { + val intent = baseIntent() + .putExtra(CallActivity.EXTRA_SCREEN_TYPE, StartingCallScreenType.Incoming.name) + .putExtra(CallActivity.EXTRA_SHOULD_ANSWER_CALL, true) + + val request = intent.toCallActivityRequest(CallActivityDestination.STARTING, mapper) + + requireNotNull(request) + assertEquals(USER_ID, request.userId.toString()) + assertEquals(CONVERSATION_ID, request.conversationId.toString()) + assertEquals(CallActivityScreen.Starting(StartingCallScreenType.Incoming), request.screen) + assertEquals(true, request.shouldAnswerCall) + } + + @Test + fun givenReplacementIntentWithoutUser_whenParsed_thenItIsRejectedAtomically() { + val intent = Intent() + .putExtra(CallActivity.EXTRA_CONVERSATION_ID, CONVERSATION_ID) + .putExtra(CallActivity.EXTRA_SCREEN_TYPE, StartingCallScreenType.Outgoing.name) + + assertNull(intent.toCallActivityRequest(CallActivityDestination.STARTING, mapper)) + } + + @Test + fun givenStartingIntentWithoutKnownScreen_whenParsed_thenItIsRejected() { + val intent = baseIntent().putExtra(CallActivity.EXTRA_SCREEN_TYPE, "unknown") + + assertNull(intent.toCallActivityRequest(CallActivityDestination.STARTING, mapper)) + } + + @Test + fun givenOngoingIntent_whenParsed_thenItDoesNotRequireStartingScreenType() { + val request = baseIntent().toCallActivityRequest(CallActivityDestination.ONGOING, mapper) + + requireNotNull(request) + assertEquals(CallActivityScreen.Ongoing, request.screen) + } + + private fun baseIntent() = Intent() + .putExtra(CallActivity.EXTRA_USER_ID, USER_ID) + .putExtra(CallActivity.EXTRA_CONVERSATION_ID, CONVERSATION_ID) + + private companion object { + const val USER_ID = "user@wire.test" + const val CONVERSATION_ID = "conversation@wire.test" + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/CallActivityViewModelOwnershipSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/CallActivityViewModelOwnershipSourceTest.kt new file mode 100644 index 00000000000..ad3c0724c8c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/calling/CallActivityViewModelOwnershipSourceTest.kt @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CallActivityViewModelOwnershipSourceTest { + + @Test + fun givenCallActivityViewModel_whenInspectingCreation_thenActivityOwnsIt() { + val activity = sourceFile("CallActivity.kt").readText() + val bindings = sourceFile("CallingMetroViewModelBindings.kt").readText() + + assertTrue(activity.contains("CallActivityViewModel by viewModels")) + assertTrue(activity.contains("Provider")) + assertFalse(activity.contains("wireMetroViewModel")) + assertFalse(bindings.contains("@ViewModelKey(CallActivityViewModel::class)")) + } + + private fun sourceFile(name: String): File { + val relative = "src/main/kotlin/com/wire/android/ui/calling/$name" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt index c6b3441ec7f..a6c00582ee4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt @@ -23,7 +23,6 @@ import android.view.View import app.cash.turbine.test import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper @@ -64,7 +63,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class SharedCallingViewModelTest { private val dispatchers = TestDispatcherProvider() diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt index 259dcd367b4..3694764ebb5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/incoming/IncomingCallViewModelTest.kt @@ -20,7 +20,6 @@ package com.wire.android.ui.calling.incoming import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestUser import com.wire.android.notification.CallNotificationManager import com.wire.android.ui.home.appLock.LockCodeTimeManager @@ -54,7 +53,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class IncomingCallViewModelTest { class Arrangement { diff --git a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt index 98f161adad8..5458b0593a0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt @@ -250,9 +250,10 @@ class CommonTopAppBarViewModelTest { } @Test - fun givenNoCurrentSession_whenGettingState_thenNone() = runTest { + fun givenGlobalCurrentSessionIsMissing_whenSessionOwnedViewModelGetsState_thenKeepsUsingInjectedAccount() = runTest { val (_, commonTopAppBarViewModel) = Arrangement() .withNotCurrentSession() + .withCurrentScreen(CurrentScreen.Home) .arrange() advanceUntilIdle() @@ -417,6 +418,7 @@ class CommonTopAppBarViewModelTest { CommonTopAppBarViewModel( currentScreenManager = currentScreenManager, coreLogic = lazyOf(coreLogic), + currentAccount = userId, params = params ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindingsSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindingsSourceTest.kt new file mode 100644 index 00000000000..96e43d73c29 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/debug/DebugMetroViewModelBindingsSourceTest.kt @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.debug + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class DebugMetroViewModelBindingsSourceTest { + + @Test + fun givenDebugViewModels_whenInspectingCreation_thenFeatureOwnsNarrowContracts() { + val bindings = source("DebugMetroViewModelBindings.kt") + val assistedViewModel = source("conversation/DebugConversationViewModel.kt") + + assertTrue(bindings.contains("object DebugMetroViewModelBindings")) + assertTrue(bindings.contains("factory: DebugConversationViewModel.Factory")) + assertTrue(bindings.contains("factory.create(args)")) + assertTrue(assistedViewModel.contains("class DebugConversationViewModel @AssistedInject constructor")) + assertTrue(assistedViewModel.contains("@Assisted val args: DebugConversationScreenNavArgs")) + assertTrue(assistedViewModel.contains("@AssistedFactory\n interface Factory")) + assertFalse(bindings.contains("DebugInfoViewModelFactory")) + assertFalse(File("src/main/kotlin/com/wire/android/ui/debug/DebugInfoViewModelFactory.kt").exists()) + } + + private fun source(name: String) = File("src/main/kotlin/com/wire/android/ui/debug/$name").readText() +} diff --git a/app/src/test/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModelTest.kt index 6c3cbf91afc..04a468a7b0e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/debug/conversation/DebugConversationViewModelTest.kt @@ -19,11 +19,8 @@ package com.wire.android.ui.debug.conversation -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestConversation import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.data.conversation.Conversation @@ -40,7 +37,6 @@ import com.wire.kalium.logic.feature.debug.GetConversationEpochFromCCUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle @@ -51,7 +47,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class DebugConversationViewModelTest { @Test @@ -143,9 +138,6 @@ class DebugConversationViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationDetailsUseCase: ObserveConversationDetailsUseCase @@ -168,9 +160,6 @@ private class Arrangement { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { - savedStateHandle.navArgs() - } returns DebugConversationScreenNavArgs(conversationId) coEvery { observeConversationDetailsUseCase(any()) } returns flowOf() coEvery { getConversationEpochFromCCUseCase(any()) } returns GetConversationEpochFromCCResult.Failure.NotMlsConversation coEvery { migrateConversationToMLSUseCase(any()) } returns MigrateConversationToMLSUseCase.Result.Success @@ -201,7 +190,7 @@ private class Arrangement { feedConversation = debugFeedConversationUseCase, getConversationEpochFromCC = getConversationEpochFromCCUseCase, migrateConversationToMLSUseCase = migrateConversationToMLSUseCase, - savedStateHandle = savedStateHandle, + args = DebugConversationScreenNavArgs(conversationId), ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindingsSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindingsSourceTest.kt new file mode 100644 index 00000000000..6f724d8cca2 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/HomeMetroViewModelBindingsSourceTest.kt @@ -0,0 +1,37 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.ui.home + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class HomeMetroViewModelBindingsSourceTest { + + @Test + fun givenHomeViewModels_whenInspectingBindings_thenFeatureOwnsNarrowCreationContracts() { + val bindings = source("HomeMetroViewModelBindings.kt") + val listViewModel = source("conversationslist/ConversationListViewModel.kt") + val homeViewModel = source("HomeViewModel.kt") + + assertTrue(bindings.contains("object HomeMetroViewModelBindings")) + assertTrue(bindings.contains("factory: ConversationListViewModelImpl.Factory")) + assertTrue(bindings.contains("factory.create(")) + assertTrue(listViewModel.contains("class ConversationListViewModelImpl @AssistedInject constructor")) + assertTrue(listViewModel.contains("@AssistedFactory\n interface Factory")) + assertTrue(homeViewModel.contains("@CurrentAccount private val currentAccount: UserId")) + assertFalse(homeViewModel.contains("CurrentSessionFlowUseCase")) + assertFalse(bindings.contains("HomeViewModelFactory")) + assertFalse(File("src/main/kotlin/com/wire/android/ui/home/HomeViewModelFactory.kt").exists()) + } + + private fun source(name: String) = File("src/main/kotlin/com/wire/android/ui/home/$name").readText() +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/HomeNavigation3EntryTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/HomeNavigation3EntryTest.kt new file mode 100644 index 00000000000..948a8952cb6 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/HomeNavigation3EntryTest.kt @@ -0,0 +1,141 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home + +import com.wire.android.navigation.HomeDestination +import com.wire.android.navigation.runtime.startup.HomeRoute +import com.wire.android.ui.home.conversations.ConversationCompletionAction +import com.wire.android.ui.home.conversations.ConversationCompletionResult +import com.wire.android.ui.userprofile.other.ConnectionRequestIgnoredResult +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class HomeNavigation3EntryTest { + + @Test + fun givenHomeRoute_whenUsedByNavigation3_thenLegacyRouteIdentityIsExact() { + val route = HomeRoute( + sessionId = WireSessionId("user", "wire.test"), + entryId = WireNavEntryId("home-entry"), + ) + + assertEquals("app/home_screen", route.routeId) + assertEquals("app/home_screen", HomeRoute.ROUTE_ID) + } + + @Test + fun givenInternalDrawerItems_whenMapped_thenEveryItemHasStableTopLevelIdentity() { + val expected = mapOf( + HomeDestination.Conversations to HomeTopLevelDestination.CONVERSATIONS, + HomeDestination.Settings to HomeTopLevelDestination.SETTINGS, + HomeDestination.Vault to HomeTopLevelDestination.VAULT, + HomeDestination.Archive to HomeTopLevelDestination.ARCHIVE, + HomeDestination.WhatsNew to HomeTopLevelDestination.WHATS_NEW, + HomeDestination.Cells to HomeTopLevelDestination.CELLS, + HomeDestination.Meetings to HomeTopLevelDestination.MEETINGS, + ) + + expected.forEach { (legacy, typed) -> + assertEquals(HomeNavigation3Target.TopLevel(typed), legacy.toNavigation3Target()) + assertEquals(legacy, typed.toHomeDestination()) + } + } + + @Test + fun givenExternalDrawerItems_whenMapped_thenTheyDoNotBecomeTopLevelSelection() { + assertEquals( + HomeNavigation3Target.External(HomeExternalDestination.SUPPORT), + HomeDestination.Support.toNavigation3Target(), + ) + assertEquals( + HomeNavigation3Target.External(HomeExternalDestination.TEAM_MANAGEMENT), + HomeDestination.TeamManagement.toNavigation3Target(), + ) + } + + @Test + fun givenSecondaryHomeDestination_whenBackIsPressed_thenConversationsIsSelected() { + HomeTopLevelDestination.entries + .filterNot { it == HomeTopLevelDestination.CONVERSATIONS } + .forEach { destination -> + assertEquals( + HomeTopLevelDestination.CONVERSATIONS, + destination.backDestination(), + "Unexpected back destination for $destination", + ) + } + } + + @Test + fun givenConversationsHomeDestination_whenBackIsPressed_thenNavigation3MayHandleRootBack() { + assertEquals(null, HomeTopLevelDestination.CONVERSATIONS.backDestination()) + } + + @Test + fun givenLeaveConversationCompletion_whenMapped_thenHomeShowsSuccessMessage() { + val message = ConversationCompletionResult( + action = ConversationCompletionAction.LEAVE_GROUP, + conversationName = "ignored for leave", + ).toHomeSnackBarMessage() + + assertEquals(HomeSnackBarMessage.LeftConversationSuccess, message) + } + + @Test + fun givenDeleteConversationCompletion_whenMapped_thenHomeShowsConversationName() { + val message = ConversationCompletionResult( + action = ConversationCompletionAction.DELETE_GROUP, + conversationName = "Design", + ).toHomeSnackBarMessage() + + assertEquals(HomeSnackBarMessage.DeletedConversationGroupSuccess("Design"), message) + } + + @Test + fun givenIgnoredConnectionRequest_whenMapped_thenHomeShowsUserName() { + val message = ConnectionRequestIgnoredResult("Alice").toHomeSnackBarMessage() + + assertEquals("Alice", (message as HomeSnackBarMessage.SuccessConnectionIgnoreRequest).userName) + } + + @Test + fun givenHomeEntry_whenInspectingResultFlow_thenNavigation3OwnsSnackbarParity() { + val source = sourceFile().readText() + + assertTrue("ConversationCompletionNavigation3ResultType" in source) + assertTrue("ConnectionRequestIgnoredNavigation3ResultType" in source) + assertTrue("runtime.navigateForResult(" in source) + assertTrue("runtime.consumeResult(" in source) + assertTrue("toHomeSnackBarMessage()" in source) + assertFalse("ResultRecipient" in source) + assertFalse("com.ramcosta.composedestinations.result.NavResult" in source) + assertFalse("com.ramcosta" in source) + } + + private fun sourceFile(): File { + val root = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt") + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/HomeStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/HomeStateHolderTest.kt deleted file mode 100644 index 708f835187c..00000000000 --- a/app/src/test/kotlin/com/wire/android/ui/home/HomeStateHolderTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.ui.home - -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.material3.DrawerState -import androidx.compose.runtime.mutableStateOf -import androidx.navigation.NavHostController -import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.Navigator -import com.wire.android.ui.common.bottomsheet.WireModalSheetState -import com.wire.android.ui.common.search.SearchBarState -import com.wire.android.ui.common.topappbar.ConversationFilterState -import com.wire.android.util.ui.LazyListStateProvider -import io.mockk.mockk -import kotlinx.coroutines.CoroutineScope -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import kotlin.coroutines.EmptyCoroutineContext - -class HomeStateHolderTest { - - @Test - fun `given active search with query when clear on next resume is requested then search is kept until resume`() { - val state = arrangement() - - state.requestClearSearchOnNextResume() - - assertTrue(state.searchBarState.isSearchActive) - assertEquals("query", state.searchBarState.searchQueryTextState.text.toString()) - - state.clearSearchOnResumeIfRequested() - - assertFalse(state.searchBarState.isSearchActive) - assertEquals("", state.searchBarState.searchQueryTextState.text.toString()) - } - - @Test - fun `given clear on next resume was consumed when resume happens again then search is not cleared again`() { - val state = arrangement() - - state.requestClearSearchOnNextResume() - state.clearSearchOnResumeIfRequested() - state.searchBarState.openSearch() - state.searchBarState.searchQueryTextState.setTextAndPlaceCursorAtEnd("new query") - - state.clearSearchOnResumeIfRequested() - - assertTrue(state.searchBarState.isSearchActive) - assertEquals("new query", state.searchBarState.searchQueryTextState.text.toString()) - } - - @Test - fun `given clear on next resume was not requested when resume happens then search is preserved`() { - val state = arrangement() - - state.clearSearchOnResumeIfRequested() - - assertTrue(state.searchBarState.isSearchActive) - assertEquals("query", state.searchBarState.searchQueryTextState.text.toString()) - } - - private fun arrangement( - searchBarState: SearchBarState = SearchBarState( - isSearchActive = true, - searchQueryTextState = TextFieldState(initialText = "query") - ) - ) = HomeStateHolder( - coroutineScope = CoroutineScope(EmptyCoroutineContext), - navController = mockk(relaxed = true), - drawerState = mockk(relaxed = true), - searchBarState = searchBarState, - conversationsFilterBottomSheetState = mockk>(relaxed = true), - newMeetingBottomSheetState = mockk>(relaxed = true), - navigator = Navigator(finish = {}, navController = mockk(relaxed = true)), - currentNavigationItemState = mutableStateOf(HomeDestination.Conversations), - conversationFilterState = ConversationFilterState(), - lazyListStateProvider = LazyListStateProvider(), - ) -} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt new file mode 100644 index 00000000000..37f44c19268 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home + +import com.wire.android.ui.home.whatsnew.WhatsNewItem +import com.wire.android.ui.home.whatsnew.WhatsNewNavigation3Target +import com.wire.android.ui.home.whatsnew.toNavigation3Target +import com.wire.android.util.ui.UIText +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class HomeTopLevelNavigation3Test { + + @Test + fun givenWhatsNewItems_whenMapped_thenOnlySemanticNavigation3TargetsCrossTheHomeBoundary() { + assertEquals( + WhatsNewNavigation3Target.Welcome, + WhatsNewItem.WelcomeToNewAndroidApp.toNavigation3Target(), + ) + assertEquals( + WhatsNewNavigation3Target.AllAndroidReleaseNotes, + WhatsNewItem.AllAndroidReleaseNotes().toNavigation3Target(), + ) + + val releaseNote = WhatsNewItem.AndroidReleaseNotes( + id = "release", + title = UIText.DynamicString("Release"), + url = "https://wire.com/release", + ) + assertEquals( + WhatsNewNavigation3Target.ExternalReleaseNote("https://wire.com/release"), + releaseNote.toNavigation3Target(), + ) + } + + @Test + fun givenNavigation3TopLevelRenderer_whenInspectingSource_thenItDoesNotHideLegacyNavigation() { + val source = sourceFile().readText() + + forbiddenLegacyTokens.forEach { token -> + assertFalse(source.contains(token), "Top-level renderer still references $token") + } + renderedDestinations.forEach { destination -> + assertTrue( + source.contains("HomeTopLevelDestination.$destination"), + "Top-level renderer does not handle $destination", + ) + } + } + + private fun sourceFile(): File { + val relative = "src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } + + private companion object { + val forbiddenLegacyTokens = listOf( + "DestinationsNavHost", + "generated.", + "NavController", + "NavigationCommand", + "WireNavigator", + ) + val renderedDestinations = listOf( + "SETTINGS", + "VAULT", + "ARCHIVE", + "WHATS_NEW", + "CELLS", + "MEETINGS", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/HomeViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/HomeViewModelTest.kt index f4991a7d0b1..5e249944945 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/HomeViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/HomeViewModelTest.kt @@ -17,25 +17,24 @@ */ package com.wire.android.ui.home -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStore +import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.framework.TestUser import com.wire.android.ui.WireActivityViewModelTest.Companion.TEST_ACCOUNT_INFO import com.wire.kalium.logic.data.user.SelfUser +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.feature.client.NeedsToRegisterClientUseCase import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.personaltoteamaccount.CanMigrateFromPersonalToTeamUseCase -import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase -import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -113,6 +112,20 @@ class HomeViewModelTest { } } + @Test + fun `given a session graph account, when checking requirements, then use that account without global session lookup`() = runTest { + val graphAccount = UserId("graph-account", "wire.test") + val (_, viewModel) = Arrangement(currentAccount = graphAccount) + .withNeedsToRegisterClientReturning(true) + .arrange() + + viewModel.actions.test { + viewModel.checkRequirements() + + assertEquals(HomeRequirement.RegisterDevice(graphAccount), expectMostRecentItem()) + } + } + @Test fun `given initial sync not completed, when checking requirements, then return HomeRequirement InitialSync`() = runTest { // given @@ -166,16 +179,15 @@ class HomeViewModelTest { assertEquals(false, viewModel.homeState.shouldShowCreateTeamUnreadIndicator) } - internal class Arrangement { - - @MockK - lateinit var savedStateHandle: SavedStateHandle + internal class Arrangement( + private val currentAccount: UserId = TEST_ACCOUNT_INFO.userId, + ) { @MockK - lateinit var globalDataStore: GlobalDataStore + lateinit var dataStore: UserDataStore @MockK - lateinit var dataStore: UserDataStore + lateinit var userDataStoreProvider: UserDataStoreProvider @MockK lateinit var observeSelfUser: ObserveSelfUserUseCase @@ -189,18 +201,14 @@ class HomeViewModelTest { @MockK lateinit var canMigrateFromPersonalToTeam: CanMigrateFromPersonalToTeamUseCase - @MockK - lateinit var currentSessionFlow: CurrentSessionFlowUseCase - private val viewModel by lazy { HomeViewModel( - savedStateHandle = savedStateHandle, - dataStore = dataStore, + userDataStoreProvider = userDataStoreProvider, + currentAccount = currentAccount, observeSelf = observeSelfUser, needsToRegisterClient = needsToRegisterClient, observeLegalHoldStatusForSelfUser = observeLegalHoldStatusForSelfUser, canMigrateFromPersonalToTeam = canMigrateFromPersonalToTeam, - currentSessionFlow = lazyOf(currentSessionFlow), ) } @@ -209,7 +217,7 @@ class HomeViewModelTest { withSelfUser(flowOf(TestUser.SELF_USER)) withCanMigrateFromPersonalToTeamReturning(true) withLegalHoldStatus(flowOf(LegalHoldStateForSelfUser.Disabled)) - coEvery { currentSessionFlow() } returns flowOf(CurrentSessionResult.Success(TEST_ACCOUNT_INFO)) + every { userDataStoreProvider.getOrCreate(currentAccount) } returns dataStore } fun withSelfUser(result: Flow) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Test.kt new file mode 100644 index 00000000000..c8c6af2a96a --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/AppLockNavigation3Test.kt @@ -0,0 +1,147 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.appLock + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import java.io.File +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class AppLockNavigation3Test { + + @Test + fun givenAppLockRoutes_whenReadingRouteIds_thenLegacyIdentitiesArePreserved() { + assertEquals("app/set_lock_code_screen", SetLockCodeRoute.ROUTE_ID) + assertEquals("app/forgot_lock_code_screen", ForgotLockCodeRoute.ROUTE_ID) + assertEquals("app/enter_lock_code_screen", EnterLockCodeRoute.ROUTE_ID) + assertEquals( + "app/app_unlock_with_biometrics_screen", + AppUnlockWithBiometricsRoute.ROUTE_ID, + ) + } + + @Test + fun givenBiometricRoute_whenSerializedAndRestored_thenSessionAndEntryIdentityArePreserved() { + val route = AppUnlockWithBiometricsRoute( + sessionId = WireSessionId("user", "wire.example"), + entryId = WireNavEntryId("biometric-entry"), + ) + + assertEquals( + route, + Json.decodeFromString(Json.encodeToString(route)), + ) + } + + @Test + fun givenTeamAppLockSetup_whenResolvingStartRoute_thenSetLockWinsOverBiometrics() { + val sessionId = WireSessionId("user", "wire.example") + + val route = resolveAppLockStartRoute( + sessionId = sessionId, + setTeamAppLock = true, + canAuthenticateWithBiometrics = true, + ) + + assertInstanceOf(SetLockCodeRoute::class.java, route) + assertEquals(sessionId, route.sessionId) + } + + @Test + fun givenBiometricsAvailable_whenResolvingUnlockStartRoute_thenBiometricRouteIsUsed() { + val sessionId = WireSessionId("user", "wire.example") + + val route = resolveAppLockStartRoute( + sessionId = sessionId, + setTeamAppLock = false, + canAuthenticateWithBiometrics = true, + ) + + assertInstanceOf(AppUnlockWithBiometricsRoute::class.java, route) + assertEquals(sessionId, route.sessionId) + } + + @Test + fun givenBiometricsUnavailable_whenResolvingUnlockStartRoute_thenPasscodeRouteIsUsed() { + val sessionId = WireSessionId("user", "wire.example") + + val route = resolveAppLockStartRoute( + sessionId = sessionId, + setTeamAppLock = false, + canAuthenticateWithBiometrics = false, + ) + + assertInstanceOf(EnterLockCodeRoute::class.java, route) + assertEquals(sessionId, route.sessionId) + } + + @Test + fun givenAppLockEntries_whenInspectingSource_thenFourTypedEntriesHaveNoNavigation2Bridge() { + val source = sourceFile("AppLockNavigation3Entries.kt").readText() + + assertEquals(4, Regex("""wireEntry<""").findAll(source).count()) + assertEquals(true, "viewModel.onAppUnlocked()" in source) + listOf( + "com.ramcosta", + "NavController", + "Bundle", + "SavedStateHandle", + "DEFAULT_ARGS_KEY", + "com.wire.android.navigation.Navigator", + "com.wire.android.navigation.NavigationCommand", + ).forEach { forbidden -> assertFalse(forbidden in source, forbidden) } + } + + @Test + fun givenAppLockActivity_whenInspectingSource_thenItUsesOnlyNavigation3HostAndMetroEnvironment() { + val source = activitySourceFile().readText() + + listOf( + "rememberWireNavigation3Runtime", + "WireNav3Host", + "MetroWireEntryEnvironment", + "SessionGraphStoreViewModel", + "finishAffinity()", + "Intent.FLAG_ACTIVITY_CLEAR_TASK", + ).forEach { expected -> assertEquals(true, expected in source, expected) } + listOf( + "com.ramcosta", + "MainNavHost", + "rememberNavigator", + "NavController", + "LoginTypeSelector", + "generated.app.destinations", + ).forEach { forbidden -> assertFalse(forbidden in source, forbidden) } + } + + private fun sourceFile(name: String): File { + val relative = "src/main/kotlin/com/wire/android/ui/home/appLock/$name" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } + + private fun activitySourceFile(): File { + val relative = "src/main/kotlin/com/wire/android/ui/AppLockActivity.kt" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModelTest.kt index 4ebe3a3a1b2..3f5f6795628 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/CompositeMessageViewModelTest.kt @@ -17,17 +17,13 @@ */ package com.wire.android.ui.home.conversations -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.ui.home.conversations.model.CompositeMessageArgs -import com.wire.android.config.NavigationTestExtension -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -36,7 +32,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class CompositeMessageViewModelTest { @Test @@ -86,17 +81,13 @@ class CompositeMessageViewModelTest { @MockK lateinit var sendButtonActionMessage: SendButtonActionMessageUseCase - @MockK - lateinit var savedStateHandle: SavedStateHandle - - private val scopedArgs = CompositeMessageArgs(MESSAGE_ID) + private val scopedArgs = CompositeMessageArgs(CONVERSATION_ID, MESSAGE_ID) init { MockKAnnotations.init(this) - every { savedStateHandle.navArgs() } returns ConversationNavArgs(CONVERSATION_ID) } - private val viewModel = CompositeMessageViewModelImpl(sendButtonActionMessage, savedStateHandle, scopedArgs) + private val viewModel = CompositeMessageViewModelImpl(sendButtonActionMessage, scopedArgs) fun withButtonActionMessage( result: SendButtonActionMessageUseCase.Result diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3SourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3SourceTest.kt new file mode 100644 index 00000000000..e0337ef8cf0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3SourceTest.kt @@ -0,0 +1,79 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ConversationAuxNavigation3SourceTest { + @Test + fun givenRouteContracts_whenInspectingSource_thenTheyStayKmpPure() { + val source = source("ui/home/conversations/ConversationAuxNavigation3.kt") + listOf("android.", "androidx.", "com.ramcosta.", "com.wire.kalium.").forEach { prefix -> + assertFalse(source.lineSequence().any { it.startsWith("import $prefix") }) + } + listOf("Bundle", "SavedStateHandle", "NavArgs", "Parcelable").forEach { + assertFalse(source.contains(it)) + } + } + + @Test + fun givenContribution_whenInspectingSource_thenAllSevenEntriesAreTyped() { + val source = source("ui/home/conversations/ConversationAuxNavigation3Entries.kt") + listOf( + "BrowseChannelsRoute", + "ConversationFoldersRoute", + "NewConversationFolderRoute", + "SearchConversationMessagesRoute", + "PromoteAdminRoute", + "AddMembersSearchRoute", + "DebugConversationRoute", + ).forEach { assertTrue(source.contains("wireEntry<$it>")) } + listOf("ScreenDestination", "SavedStateHandle", "Bundle", "NavController").forEach { + assertFalse(source.contains(it)) + } + } + + @Test + fun givenArgumentOwningViewModels_whenInspectingSources_thenArgumentsAreTyped() { + listOf( + "ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModel.kt", + "ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt", + "ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt", + "ui/debug/conversation/DebugConversationViewModel.kt", + ).forEach { path -> + val source = source(path) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("generated.app.navArgs")) + } + } + + @Test + fun givenAddMembersCompletes_whenInspectingScreen_thenBackStackMutationRunsAsAnEffect() { + val source = source( + "ui/home/conversations/search/adddembertoconversation/AddMembersSearchScreen.kt" + ) + + assertTrue(source.contains("LaunchedEffect(viewModel.newGroupState.isCompleted)")) + assertTrue(source.contains("onNavigateBack()")) + } + + private fun source(path: String): String { + val userDir = requireNotNull(System.getProperty("user.dir")) + val root = generateSequence(File(userDir)) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/$path") + .also { assertTrue(it.isFile) } + .readText() + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Test.kt new file mode 100644 index 00000000000..bb16d547e35 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationAuxNavigation3Test.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ConversationAuxNavigation3Test { + private val session = WireSessionId("user", "wire.example") + private val conversation = ConversationAuxId("conversation", "wire.example") + + @Test + fun givenConversationAuxRoutes_whenSerialized_thenArgumentsAndIdentitySurvive() { + assertRoundTrip(BrowseChannelsRoute(session, WireNavEntryId("browse"))) + assertRoundTrip( + ConversationFoldersRoute(session, conversation, "name", "folder", WireNavEntryId("folders")) + ) + assertRoundTrip(NewConversationFolderRoute(session, "folders", WireNavEntryId("new-folder"))) + assertRoundTrip( + SearchConversationMessagesRoute(session, conversation, "name", true, WireNavEntryId("search")) + ) + assertRoundTrip( + PromoteAdminRoute( + session, + conversation, + listOf(ConversationAuxId("admin", "wire.example")), + WireNavEntryId("promote"), + ) + ) + assertRoundTrip( + AddMembersSearchRoute( + session, + conversation, + isConversationAppsEnabled = true, + isSelfPartOfATeam = true, + protocol = ConversationProtocolSelection.Mixed( + groupId = "group", + groupState = ConversationProtocolSelection.GroupState.ESTABLISHED, + epoch = 42u, + keyingMaterialLastUpdate = "2026-01-01T00:00:00Z", + cipherSuiteTag = 1, + ), + entryId = WireNavEntryId("members"), + ) + ) + assertRoundTrip(DebugConversationRoute(session, conversation, WireNavEntryId("debug"))) + } + + @Test + fun givenSameRoute_whenCreatedTwice_thenEntryIdentityIsUnique() { + val first = BrowseChannelsRoute(session) + val second = BrowseChannelsRoute(session) + + assertNotEquals(first.entryId, second.entryId) + assertEquals(first.routeId, second.routeId) + } + + private inline fun assertRoundTrip(value: T) { + assertEquals(value, Json.decodeFromString(Json.encodeToString(value))) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3SourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3SourceTest.kt new file mode 100644 index 00000000000..db157e0bacc --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3SourceTest.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations + +import java.io.File +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ConversationNavigation3SourceTest { + + @Test + fun givenConversationEntry_whenRegistered_thenItRetainsRootHorizontalPresentation() { + val source = File(conversationSourceDirectory(), "ConversationNavigation3Entries.kt").readText() + + assertTrue( + "wireEntry(presentation = WireEntryPresentation.Slide)" in source + ) + } + + @Test + fun givenConversationSources_whenCheckingNavigationDependencies_thenOnlyNavigation3ContractsAreUsed() { + val sourceDirectory = conversationSourceDirectory() + val forbiddenReferences = listOf( + "com.ramcosta", + "WireNavigator", + "com.wire.android.navigation.NavigationCommand", + "ResultRecipient", + "ResultBackNavigator", + "androidx.navigation", + "SavedStateHandle", + ".navArgs", + ) + val violations = sourceDirectory + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines() + .mapIndexedNotNull { index, line -> + forbiddenReferences + .firstOrNull(line::contains) + ?.let { reference -> "${file.relativeTo(sourceDirectory)}:${index + 1}: $reference" } + } + .asSequence() + } + .toList() + + assertTrue( + violations.isEmpty(), + "Conversation sources still contain legacy navigation references:\n${violations.joinToString("\n")}", + ) + } + + private fun conversationSourceDirectory(): File = + generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile } + .map { File(it, "app/src/main/kotlin/com/wire/android/ui/home/conversations") } + .firstOrNull(File::isDirectory) + ?: error("Unable to locate the conversations production source directory") +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Test.kt new file mode 100644 index 00000000000..b78cec67d44 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Test.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.ui.home.conversations + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ConversationNavigation3Test { + private val session = WireSessionId("user", "wire.example") + + @Test + fun givenConversationRouteWithPendingShare_whenSerialized_thenAllTypedArgumentsSurvive() { + val route = ConversationRoute( + sessionId = session, + conversationId = ConversationRouteId("conversation", "wire.example"), + searchedMessageId = "message", + pendingAssets = listOf( + ConversationPendingAsset( + key = "asset", + mimeType = "image/png", + dataPath = "/cache/image", + dataSize = 42, + fileName = "image.png", + assetType = "IMAGE", + audioWavesMask = listOf(1, 2), + ) + ), + pendingText = "shared text", + entryId = WireNavEntryId("conversation-entry"), + ) + + assertEquals(route, Json.decodeFromString(Json.encodeToString(route))) + assertTrue(route is SessionRoute) + assertEquals(session, route.sessionId) + } + + @Test + fun givenConversationCompletion_whenSerialized_thenActionAndNameSurvive() { + ConversationCompletionAction.entries.forEach { action -> + val result = ConversationCompletionResult(action, "Conversation") + assertEquals( + result, + Json.decodeFromString(Json.encodeToString(result)), + ) + } + } + + @Test + fun givenSameConversation_whenOpenedTwice_thenEntryIdentityIsUnique() { + val id = ConversationRouteId("conversation", "wire.example") + assertNotEquals( + ConversationRoute(session, id).entryId, + ConversationRoute(session, id).entryId, + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModelTest.kt index 195d3c34db4..f8c51688de0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/attachment/MessageAttachmentsViewModelTest.kt @@ -17,8 +17,6 @@ */ package com.wire.android.ui.home.conversations.attachment -import androidx.lifecycle.SavedStateHandle -import com.ramcosta.composedestinations.generated.app.navargs.toSavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.MessageSharedState @@ -326,11 +324,9 @@ class MessageAttachmentsViewModelTest { private class Arrangement { - // Use the generated toSavedStateHandle() extension which correctly serializes via - // qualifiedIDNavType (QualifiedID uses @SerialName("id") for the value field, not "value"). - private val savedStateHandle: SavedStateHandle = ConversationNavArgs( + private val navigationArgs = ConversationNavArgs( conversationId = ConversationId("conv-value", "conv-domain") - ).toSavedStateHandle() + ) @MockK lateinit var handleUriAsset: HandleUriAssetUseCase @@ -398,7 +394,7 @@ class MessageAttachmentsViewModelTest { fun arrange(): Pair { initializeMocks() val viewModel = MessageAttachmentsViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = navigationArgs, handleUriAsset = handleUriAsset, observeAttachments = observeAttachments, addAttachment = addAttachment, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModelTest.kt index fbe77a92dd7..7985d85a13a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/ConversationBannerViewModelTest.kt @@ -18,15 +18,12 @@ package com.wire.android.ui.home.conversations.banner -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.mockUri import com.wire.android.framework.TestConversationDetails import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.banner.usecase.ObserveConversationMembersByTypesUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.data.user.type.UserTypeInfo @@ -35,7 +32,6 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -47,7 +43,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class ConversationBannerViewModelTest { @Test @@ -129,9 +124,6 @@ class ConversationBannerViewModelTest { private class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationMembersByTypesUseCase: ObserveConversationMembersByTypesUseCase @@ -143,10 +135,10 @@ private class Arrangement { private val viewModel by lazy { ConversationBannerViewModel( - savedStateHandle, - observeConversationMembersByTypesUseCase, - observeConversationDetailsUseCase, - notifyConversationIsOpenUseCase + observeConversationMembersByTypes = observeConversationMembersByTypesUseCase, + observeConversationDetails = observeConversationDetailsUseCase, + notifyConversationIsOpen = notifyConversationIsOpenUseCase, + navigationArgs = ConversationNavArgs(conversationId), ) } val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") @@ -155,7 +147,6 @@ private class Arrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) // Default empty values coEvery { observeConversationMembersByTypesUseCase(any()) } returns flowOf() coEvery { notifyConversationIsOpenUseCase(any()) } returns Unit diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/SecurityClassificationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/SecurityClassificationViewModelTest.kt index 6889a9cb321..04c8734372b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/SecurityClassificationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/banner/SecurityClassificationViewModelTest.kt @@ -18,25 +18,18 @@ package com.wire.android.ui.home.conversations.banner import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.ui.common.banner.SecurityClassificationArgs import com.wire.android.ui.common.banner.SecurityClassificationViewModelImpl import com.wire.kalium.logic.CoreLogic -import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.SecurityClassificationType -import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase -import com.wire.kalium.logic.feature.session.CurrentSessionResult import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -44,7 +37,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class SecurityClassificationViewModelTest { @Test @@ -82,42 +74,19 @@ class SecurityClassificationViewModelTest { } @Test - fun `given logout while observing classification, when current session disappears, then should reset state to none`() = runTest { - // Given - val arrangement = Arrangement( - conversationId = CONVERSATION_ID, - userId = null - ) - arrangement.withCurrentSessionsFlow(arrangement.currentSessionStateFlow) - val (_, viewModel) = arrangement.arrange() - - // When - arrangement.currentSessionStateFlow.emit(CurrentSessionResult.Success(AccountInfo.Valid(USER_ID))) - arrangement.classificationChannel.send(SecurityClassificationType.CLASSIFIED) - advanceUntilIdle() - arrangement.currentSessionStateFlow.emit(CurrentSessionResult.Failure.SessionNotFound) - advanceUntilIdle() - - // Then - assertEquals(SecurityClassificationType.NONE, viewModel.state()) - } - - @Test - fun `given no current session, when creating view model, then should stay in none state without querying session scope`() = runTest { - // Given + fun `given session-owned entry, when observing classification, then injected account selects graph regardless of global session`() = runTest { val (arrangement, viewModel) = Arrangement( conversationId = CONVERSATION_ID, userId = null ) - .withCurrentSessionsFlow(flowOf(CurrentSessionResult.Failure.SessionNotFound)) .arrange() + arrangement.classificationChannel.send(SecurityClassificationType.CLASSIFIED) advanceUntilIdle() - // Then - assertEquals(SecurityClassificationType.NONE, viewModel.state()) - coVerify(exactly = 0) { - arrangement.coreLogic.getSessionScope(any()).observeSecurityClassificationLabel(any()) + assertEquals(SecurityClassificationType.CLASSIFIED, viewModel.state()) + coVerify(exactly = 1) { + arrangement.coreLogic.getSessionScope(USER_ID) } } @@ -134,12 +103,8 @@ class SecurityClassificationViewModelTest { @MockK lateinit var coreLogic: CoreLogic - @MockK - lateinit var currentSessionFlowUseCase: CurrentSessionFlowUseCase - val classificationChannel = Channel(capacity = Channel.UNLIMITED) val classificationUserChannel = Channel(capacity = Channel.UNLIMITED) - val currentSessionStateFlow = MutableSharedFlow(replay = 1) private val navArg: SecurityClassificationArgs = when { conversationId != null -> SecurityClassificationArgs.Conversation(conversationId) userId != null -> SecurityClassificationArgs.User(userId) @@ -148,8 +113,6 @@ class SecurityClassificationViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { coreLogic.getGlobalScope().session.currentSessionFlow } returns currentSessionFlowUseCase - coEvery { currentSessionFlowUseCase() } returns flowOf(CurrentSessionResult.Success(AccountInfo.Valid(USER_ID))) coEvery { coreLogic.getSessionScope(any()).observeSecurityClassificationLabel(any()) } returns classificationChannel.consumeAsFlow() @@ -157,13 +120,10 @@ class SecurityClassificationViewModelTest { classificationUserChannel.consumeAsFlow() } - fun withCurrentSessionsFlow(result: kotlinx.coroutines.flow.Flow) = apply { - coEvery { currentSessionFlowUseCase() } returns result - } - fun arrange() = this to SecurityClassificationViewModelImpl( - coreLogic, - navArg + coreLogic = coreLogic, + currentAccount = USER_ID, + args = navArg, ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt index beb4c274ffc..667df7739d5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModelTest.kt @@ -17,11 +17,8 @@ */ package com.wire.android.ui.home.conversations.call -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestConversationDetails import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData @@ -60,7 +57,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class ConversationCallViewModelTest { @Test @@ -152,9 +149,6 @@ class ConversationCallViewModelTest { } private class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var observeJoinableCalls: ObserveJoinableCallsUseCase @@ -196,7 +190,6 @@ class ConversationCallViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) coEvery { observeEstablishedCalls.invoke() } returns emptyFlow() coEvery { observeJoinableCalls.invoke() } returns emptyFlow() coEvery { observeConversationDetails(any()) } returns flowOf() @@ -228,7 +221,7 @@ class ConversationCallViewModelTest { } fun arrange(): Pair = this to ConversationCallViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = ConversationNavArgs(conversationId), observeJoinableCalls = observeJoinableCalls, observeEstablishedCalls = observeEstablishedCalls, answerCall = joinCall, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index bdb2166d84c..e7f0113b62e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -19,8 +19,6 @@ package com.wire.android.ui.home.conversations.composer import android.net.Uri -import androidx.lifecycle.SavedStateHandle -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.datastore.GlobalDataStore @@ -90,7 +88,6 @@ internal class MessageComposerViewModelArrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) // Default empty values coEvery { isFileSharingEnabledUseCase() } returns FileSharingStatus(FileSharingStatus.Value.EnabledAll, null) @@ -108,9 +105,6 @@ internal class MessageComposerViewModelArrangement { coEvery { markConversationAsReadLocallyUseCase(any(), any()) } returns MarkConversationAsReadResult.Success(false) } - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var isFileSharingEnabledUseCase: IsFileSharingEnabledUseCase @@ -166,7 +160,7 @@ internal class MessageComposerViewModelArrangement { private val viewModel by lazy { MessageComposerViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = ConversationNavArgs(conversationId), dispatchers = TestDispatcherProvider(), isFileSharingEnabled = isFileSharingEnabledUseCase, updateConversationReadDate = updateConversationReadDateUseCase, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt index 0c01823ba06..67a2fccff85 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home.conversations.composer import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.feature.session.CurrentSessionResult @@ -36,7 +35,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) @Suppress("LargeClass") class MessageComposerViewModelTest { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3SourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3SourceTest.kt new file mode 100644 index 00000000000..2883c414612 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3SourceTest.kt @@ -0,0 +1,79 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations.details + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ConversationDetailsNavigation3SourceTest { + @Test + fun givenRouteContracts_whenInspectingSource_thenTheyStayKmpPure() { + val source = source("ConversationDetailsNavigation3.kt") + listOf("android.", "androidx.", "com.ramcosta.", "com.wire.kalium.").forEach { + assertFalse(source.lineSequence().any { line -> line.startsWith("import $it") }) + } + listOf("Bundle", "SavedStateHandle", "NavArgs", "Parcelable").forEach { + assertFalse(source.contains(it)) + } + } + + @Test + fun givenContribution_whenInspectingSource_thenAllEightEntriesUseTypedFactories() { + val source = source("ConversationDetailsNavigation3Entries.kt") + listOf( + "GroupConversationDetailsRoute", + "EditConversationNameRoute", + "EditSelfDeletingMessagesRoute", + "GroupConversationAllParticipantsRoute", + "UpdateAppsAccessRoute", + "ChannelAccessOnUpdateRoute", + "EditGuestAccessRoute", + "CreatePasswordProtectedGuestLinkRoute", + ).forEach { assertTrue(source.contains("wireEntry<$it>")) } + assertFalse(source.contains("ScreenDestination")) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("Bundle")) + assertTrue(source.contains("toViewModelArgs()")) + } + + @Test + fun givenMigratedViewModels_whenInspectingSources_thenArgumentsAreTyped() { + viewModelPaths.forEach { path -> + val source = source(path) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("generated.app.navArgs")) + assertTrue(source.contains("navigationArgs:")) + } + } + + private fun source(path: String): String { + val root = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/ui/home/conversations/details/$path") + .also { assertTrue(it.isFile) } + .readText() + } + + private companion object { + val viewModelPaths = listOf( + "GroupConversationDetailsViewModel.kt", + "metadata/EditConversationMetadataViewModel.kt", + "editselfdeletingmessages/EditSelfDeletingMessagesViewModel.kt", + "participants/GroupConversationParticipantsViewModel.kt", + "updateappsaccess/UpdateAppsAccessViewModel.kt", + "updatechannelaccess/UpdateChannelAccessViewModel.kt", + "editguestaccess/EditGuestAccessViewModel.kt", + "editguestaccess/createPasswordProtectedGuestLink/CreatePasswordGuestLinkViewModel.kt", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Test.kt new file mode 100644 index 00000000000..bca36a2749c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/ConversationDetailsNavigation3Test.kt @@ -0,0 +1,57 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversations.details + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ConversationDetailsNavigation3Test { + private val session = WireSessionId("user", "wire.example") + private val conversation = ConversationDetailsId("conversation", "wire.example") + + @Test + fun givenAllConversationDetailsRoutes_whenSerialized_thenIdentityAndArgumentsSurvive() { + assertRoundTrip(GroupConversationDetailsRoute(session, conversation, WireNavEntryId("details"))) + assertRoundTrip(EditConversationNameRoute(session, conversation, WireNavEntryId("name"))) + assertRoundTrip(EditSelfDeletingMessagesRoute(session, conversation, WireNavEntryId("timer"))) + assertRoundTrip(GroupConversationAllParticipantsRoute(session, conversation, WireNavEntryId("participants"))) + assertRoundTrip(UpdateAppsAccessRoute(session, conversation, true, false, true, WireNavEntryId("apps"))) + assertRoundTrip( + ChannelAccessOnUpdateRoute( + session, + conversation, + ChannelAccessSelection.PUBLIC, + ChannelPermissionSelection.EVERYONE, + WireNavEntryId("channel"), + ) + ) + assertRoundTrip(EditGuestAccessRoute(session, conversation, true, false, true, WireNavEntryId("guests"))) + assertRoundTrip(CreatePasswordProtectedGuestLinkRoute(session, conversation, WireNavEntryId("guest-link"))) + } + + @Test + fun givenSameDestination_whenCreatedTwice_thenEntryIdentityIsUnique() { + val first = GroupConversationDetailsRoute(session, conversation) + val second = GroupConversationDetailsRoute(session, conversation) + + assertNotEquals(first.entryId, second.entryId) + assertEquals(first.routeId, second.routeId) + } + + private inline fun assertRoundTrip(route: T) { + assertEquals(route, Json.decodeFromString(Json.encodeToString(route))) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt index 1bf7c4be696..fa72864bff0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt @@ -19,21 +19,17 @@ package com.wire.android.ui.home.conversations.details -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversation import com.wire.android.framework.TestTeam import com.wire.android.framework.TestUser import com.wire.android.mapper.testUIParticipant import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState -import com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsNavArgs import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType import com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.Group.Channel.ChannelAccess @@ -66,7 +62,6 @@ import com.wire.kalium.logic.feature.user.IsMLSEnabledUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -80,7 +75,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class GroupDetailsViewModelTest { @Test fun `given a group conversation, when solving the conversation name, then the name of the conversation is used`() = runTest { @@ -742,9 +736,6 @@ class GroupDetailsViewModelTest { internal class GroupConversationDetailsViewModelArrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase @@ -793,7 +784,7 @@ internal class GroupConversationDetailsViewModelArrangement { observeConversationMembers = observeParticipantsForConversationUseCase, observeSelfUserWithTeam = observeSelfUserWithTeam, observeIsAppsAllowedForUsage = observeIsAppsAllowedForUsage, - savedStateHandle = savedStateHandle, + navigationArgs = GroupConversationDetailsNavArgs(conversationId), updateConversationReceiptMode = updateConversationReceiptMode, isMLSEnabled = isMLSEnabledUseCase, observeSelfDeletionTimerSettingsForConversation = observeSelfDeletionTimerSettingsForConversation, @@ -808,15 +799,6 @@ internal class GroupConversationDetailsViewModelArrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) - every { - savedStateHandle.navArgs() - } returns GroupConversationAllParticipantsNavArgs( - conversationId = conversationId - ) - every { savedStateHandle.navArgs() } returns GroupConversationDetailsNavArgs( - conversationId = conversationId - ) - // Default empty values coEvery { observeConversationDetails(any()) } returns flowOf() updateSelfUserWithTeamFlow() diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/CreatePasswordGuestLinkViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/CreatePasswordGuestLinkViewModelTest.kt index 3ba3eb04e9e..1321d6c3431 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/CreatePasswordGuestLinkViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/CreatePasswordGuestLinkViewModelTest.kt @@ -18,12 +18,9 @@ package com.wire.android.ui.home.conversations.details.editguestaccess import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.wire.android.config.NavigationTestExtension import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkNavArgs import com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.auth.ValidatePasswordResult @@ -52,11 +49,7 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith( - NavigationTestExtension::class -) class CreatePasswordGuestLinkViewModelTest { private val dispatcher: TestDispatcher = StandardTestDispatcher() @@ -273,9 +266,6 @@ class CreatePasswordGuestLinkViewModelTest { private class Arrangement(private val dispatcher: TestDispatcher) { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var generateGuestRoomLink: GenerateGuestRoomLinkUseCase @@ -287,11 +277,6 @@ class CreatePasswordGuestLinkViewModelTest { init { MockKAnnotations.init(this) - every { - savedStateHandle.navArgs() - } returns CreatePasswordGuestLinkNavArgs( - conversationId = CONVERSATION_ID - ) } fun withPasswordValidation(result: Boolean) = apply { @@ -331,7 +316,7 @@ class CreatePasswordGuestLinkViewModelTest { generateGuestRoomLink = generateGuestRoomLink, validatePassword = validatePassword, generatePassword = generateRandomPassword, - savedStateHandle = savedStateHandle + navigationArgs = CreatePasswordGuestLinkNavArgs(CONVERSATION_ID), ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt index b11e80a669c..322e72f085a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt @@ -20,16 +20,13 @@ package com.wire.android.ui.home.conversations.details.editguestaccess -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversation import com.wire.android.framework.TestConversationDetails import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModelTest import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.error.NetworkFailure @@ -50,7 +47,6 @@ import com.wire.kalium.logic.feature.user.guestroomlink.ObserveGuestRoomLinkFeat import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -64,7 +60,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class EditGuestAccessViewModelTest { private val dispatcher = TestDispatcherProvider() @@ -256,9 +252,6 @@ class EditGuestAccessViewModelTest { } private class Arrangement(dispatcherProvider: TestDispatcherProvider) { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase @@ -294,7 +287,14 @@ class EditGuestAccessViewModelTest { val editGuestAccessViewModel: EditGuestAccessViewModel by lazy { EditGuestAccessViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = EditGuestAccessNavArgs( + conversationId = OtherUserProfileScreenViewModelTest.CONVERSATION_ID, + editGuessAccessParams = EditGuestAccessParams( + isGuestAccessAllowed = true, + isServicesAllowed = true, + isUpdatingGuestAccessAllowed = true, + ), + ), observeConversationDetails = observeConversationDetails, observeConversationMembers = observeConversationMembers, updateConversationAccessRole = updateConversationAccessRole, @@ -312,14 +312,6 @@ class EditGuestAccessViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns EditGuestAccessNavArgs( - conversationId = OtherUserProfileScreenViewModelTest.CONVERSATION_ID, - editGuessAccessParams = EditGuestAccessParams( - isGuestAccessAllowed = true, - isServicesAllowed = true, - isUpdatingGuestAccessAllowed = true - ) - ) coEvery { observeConversationDetails(any()) } returns flowOf() coEvery { observeConversationMembers(any()) } returns flowOf() coEvery { observeGuestRoomLink(any()) } returns flowOf() diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModelTest.kt index 48088ba1152..0f5dea076ab 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editselfdeletingmessages/EditSelfDeletingMessagesViewModelTest.kt @@ -17,9 +17,7 @@ */ package com.wire.android.ui.home.conversations.details.editselfdeletingmessages -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversation import com.wire.android.framework.TestConversationDetails @@ -27,7 +25,6 @@ import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.messagetimer.UpdateMessageTimerUseCase @@ -35,7 +32,6 @@ import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTim import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -48,7 +44,6 @@ import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class EditSelfDeletingMessagesViewModelTest { @Test @@ -87,9 +82,6 @@ class EditSelfDeletingMessagesViewModelTest { private class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var observerConversationMembers: ObserveParticipantsForConversationUseCase @@ -107,7 +99,7 @@ class EditSelfDeletingMessagesViewModelTest { private val viewModel by lazy { EditSelfDeletingMessagesViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = EditSelfDeletingMessagesNavArgs(TestConversation.ID), dispatcher = TestDispatcherProvider(), observeConversationMembers = observerConversationMembers, observeSelfDeletionTimerSettingsForConversation = observeSelfDeletionTimerSettingsForConversation, @@ -119,10 +111,6 @@ class EditSelfDeletingMessagesViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns EditSelfDeletingMessagesNavArgs( - conversationId = TestConversation.ID - ) - coEvery { selfUser() } returns flowOf(TestUser.SELF_USER) coEvery { conversationDetails(any()) } returns flowOf( ObserveConversationDetailsUseCase.Result.Success(TestConversationDetails.GROUP) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModelTest.kt index d19cf2bcded..6b494223609 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/metadata/EditConversationMetadataViewModelTest.kt @@ -19,10 +19,7 @@ package com.wire.android.ui.home.conversations.details.metadata import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversation @@ -33,7 +30,6 @@ import com.wire.kalium.logic.feature.conversation.RenamingResult import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -43,7 +39,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class, SnapshotExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class EditConversationMetadataViewModelTest { @Test @@ -62,9 +58,6 @@ class EditConversationMetadataViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase @@ -73,9 +66,6 @@ class EditConversationMetadataViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns EditConversationNameNavArgs( - conversationId = TestConversation.ID - ) coEvery { observeConversationDetails(any()) } returns flowOf( ObserveConversationDetailsUseCase.Result.Success(TestConversationDetails.GROUP) ) @@ -86,7 +76,7 @@ class EditConversationMetadataViewModelTest { dispatcher = TestDispatcherProvider(), observeConversationDetails = observeConversationDetails, renameConversation = renameConversation, - savedStateHandle = savedStateHandle + navigationArgs = EditConversationNameNavArgs(TestConversation.ID) ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupParticipantsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupParticipantsViewModelTest.kt index 18550ad1d2b..504ee006c97 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupParticipantsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupParticipantsViewModelTest.kt @@ -18,21 +18,17 @@ package com.wire.android.ui.home.conversations.details.participants -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.mockUri import com.wire.android.mapper.testUIParticipant import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -44,7 +40,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class GroupParticipantsViewModelTest { private fun testSize(givenParticipantsSize: Int, expectedParticipantsSize: Int) = runTest { @@ -74,9 +69,6 @@ class GroupParticipantsViewModelTest { internal class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase @@ -89,9 +81,6 @@ internal class Arrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { - savedStateHandle.navArgs() - } returns GroupConversationAllParticipantsNavArgs(conversationId = conversationId) // Default empty values coEvery { observeParticipantsForConversationUseCase(any(), any()) } returns flowOf() } @@ -110,7 +99,7 @@ internal class Arrangement { fun arrange(maxNumberOfItems: Int = -1): Pair = this to GroupConversationParticipantsViewModel( - savedStateHandle, + GroupConversationAllParticipantsNavArgs(conversationId), observeParticipantsForConversationUseCase, refreshUsersWithoutMetadata, maxNumberOfItems, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt index 9370089857a..e1864eca4fc 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updateappsaccess/UpdateAppsAccessViewModelTest.kt @@ -17,16 +17,13 @@ */ package com.wire.android.ui.home.conversations.details.updateappsaccess -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversation import com.wire.android.framework.TestConversationDetails import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.MutedConversationStatus @@ -45,7 +42,6 @@ import com.wire.kalium.logic.feature.user.ObserveSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -59,7 +55,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class UpdateAppsAccessViewModelTest { @Test @@ -394,9 +390,6 @@ class UpdateAppsAccessViewModelTest { internal class UpdateAppsAccessViewModelArrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase @@ -417,6 +410,15 @@ internal class UpdateAppsAccessViewModelArrangement { private val observeParticipantsForConversationFlow = MutableSharedFlow(replay = Int.MAX_VALUE) + private var navigationArgs = UpdateAppsAccessNavArgs( + conversationId = ConversationId("some-dummy-value", "dummyDomain"), + updateAppsAccessParams = UpdateAppsAccessParams( + isGuestAllowed = true, + isAppsAllowed = true, + shouldUseNewAppsUi = true, + ), + ) + private val viewModel by lazy { UpdateAppsAccessViewModel( dispatcher = TestDispatcherProvider(), @@ -425,7 +427,7 @@ internal class UpdateAppsAccessViewModelArrangement { changeAccessForAppsInConversation = changeAccessForAppsInConversationUseCase, observeIsAppsAllowedForUsage = observeIsAppsAllowedForUsage, selfUser = observeSelfUser, - savedStateHandle = savedStateHandle + navigationArgs = navigationArgs, ) } @@ -435,15 +437,6 @@ internal class UpdateAppsAccessViewModelArrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns UpdateAppsAccessNavArgs( - conversationId = conversationId, - updateAppsAccessParams = UpdateAppsAccessParams( - isGuestAllowed = true, - isAppsAllowed = true, - shouldUseNewAppsUi = true - ) - ) - // Default empty values coEvery { observeConversationDetails(any()) } returns flowOf() coEvery { observeParticipantsForConversationUseCase(any()) } returns flowOf() @@ -452,13 +445,13 @@ internal class UpdateAppsAccessViewModelArrangement { } fun withGuestDisabledNavArgs() = apply { - every { savedStateHandle.navArgs() } returns UpdateAppsAccessNavArgs( + navigationArgs = UpdateAppsAccessNavArgs( conversationId = conversationId, updateAppsAccessParams = UpdateAppsAccessParams( isGuestAllowed = false, isAppsAllowed = true, - shouldUseNewAppsUi = true - ) + shouldUseNewAppsUi = true, + ), ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModelTest.kt index 50441450beb..73314a6b985 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/updatechannelaccess/UpdateChannelAccessViewModelTest.kt @@ -17,19 +17,14 @@ */ package com.wire.android.ui.home.conversations.details.updatechannelaccess -import androidx.lifecycle.SavedStateHandle import com.wire.android.framework.TestUser -import com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnUpdateScreenDestination import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType import com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.feature.conversation.channel.UpdateChannelAddPermissionUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher @@ -94,31 +89,19 @@ class UpdateChannelAccessViewModelTest { private class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK private lateinit var updateChannelAddPermission: UpdateChannelAddPermissionUseCase private val viewModel by lazy { UpdateChannelAccessViewModel( updateChannelAddPermission = updateChannelAddPermission, - savedStateHandle = savedStateHandle, + navigationArgs = UpdateChannelAccessArgs("conversationId"), qualifiedIdMapper = QualifiedIdMapper(TestUser.SELF_USER_ID) ) } init { - val conversationId = "conversationId" MockKAnnotations.init(this, relaxUnitFun = true) - mockkObject(ChannelAccessOnUpdateScreenDestination) - every { - ChannelAccessOnUpdateScreenDestination.argsFrom(any()) - } answers { - UpdateChannelAccessArgs(conversationId) - } - - every { savedStateHandle.navArgs() } returns UpdateChannelAccessArgs(conversationId) } fun withUpdateChannelAddPermissionUseCaseReturning( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt index 421a0ff0b0d..b6fa351d135 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt @@ -18,11 +18,9 @@ package com.wire.android.ui.home.conversations.info -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.mockUri import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.ConversationNavArgs -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.common.error.StorageFailure import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId @@ -50,9 +48,6 @@ class ConversationInfoViewModelArrangement { @MockK lateinit var qualifiedIdMapper: QualifiedIdMapper - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase @@ -65,7 +60,7 @@ class ConversationInfoViewModelArrangement { private val viewModel: ConversationInfoViewModel by lazy { ConversationInfoViewModel( qualifiedIdMapper = qualifiedIdMapper, - savedStateHandle = savedStateHandle, + navigationArgs = ConversationNavArgs(conversationId), observeConversationDetails = observeConversationDetails, fetchConversationMLSVerificationStatus = fetchConversationMLSVerificationStatus, selfUserId = TestUser.SELF_USER_ID, @@ -76,7 +71,6 @@ class ConversationInfoViewModelArrangement { init { MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) every { qualifiedIdMapper.fromStringToQualifiedID("some-dummy-value@some.dummy.domain") diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt index 9e3686ea548..e9be302daaa 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home.conversations.info import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.composer.mockConversationDetailsGroup import com.wire.android.ui.home.conversations.composer.withMockConversationDetailsOneOnOne @@ -37,7 +36,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class ConversationInfoViewModelTest { @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index 91ecc8cda8a..999e0ad4168 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -18,9 +18,7 @@ package com.wire.android.ui.home.conversations.messages -import androidx.lifecycle.SavedStateHandle import androidx.paging.PagingData -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.media.audiomessage.AudioSpeed @@ -82,9 +80,6 @@ class ConversationMessagesViewModelArrangement { private val messagesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 10) val networkState = MutableStateFlow(NetworkState.ConnectedWithInternet) - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getMessagesForConversationUseCase: GetMessagesForConversationUseCase @@ -138,7 +133,7 @@ class ConversationMessagesViewModelArrangement { private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( - savedStateHandle, + ConversationNavArgs(conversationId = conversationId), observeConversationDetails, getMessageAsset, getMessageById, @@ -164,7 +159,6 @@ class ConversationMessagesViewModelArrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) coEvery { toggleReaction(any(), any(), any()) } returns ToggleReactionResult.Success coEvery { observeConversationDetails(any()) } returns flowOf() coEvery { getMessagesForConversationUseCase(any(), any()) } returns messagesFlow diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index d8bed4ba394..8041c8d209f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -24,7 +24,6 @@ import androidx.paging.testing.asSnapshot import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestMessage import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT import com.wire.android.media.audiomessage.AudioMediaPlayingState @@ -68,7 +67,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class ConversationMessagesViewModelTest { @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt index d298e7a0c13..07a55d887e1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt @@ -17,15 +17,11 @@ */ package com.wire.android.ui.home.conversations.messages -import android.os.Bundle import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.core.os.bundleOf -import androidx.lifecycle.SavedStateHandle import androidx.paging.PagingData import androidx.paging.map import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -36,13 +32,11 @@ import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesNavArgs import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel import com.wire.android.ui.home.conversations.usecase.GetConversationMessagesFromSearchUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -56,7 +50,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class, SnapshotExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class SearchConversationMessagesViewModelTest { @Test @@ -208,9 +202,6 @@ class SearchConversationMessagesViewModelTest { private val messagesChannel = Channel>(capacity = Channel.UNLIMITED) - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getSearchMessagesForConversation: GetConversationMessagesFromSearchUseCase @@ -218,7 +209,7 @@ class SearchConversationMessagesViewModelTest { SearchConversationMessagesViewModel( getSearchMessagesForConversation = getSearchMessagesForConversation, dispatchers = TestDispatcherProvider(), - savedStateHandle = savedStateHandle + searchConversationMessagesNavArgs = SearchConversationMessagesNavArgs(conversationId), ) } @@ -226,12 +217,6 @@ class SearchConversationMessagesViewModelTest { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns SearchConversationMessagesNavArgs( - conversationId = conversationId - ) - every { savedStateHandle.get("searchConversationMessagesState") } returns bundleOf( - "value" to "" - ) coEvery { getSearchMessagesForConversation( any(), diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt index ca31d42e995..4c4e60fce06 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt @@ -17,17 +17,14 @@ */ package com.wire.android.ui.home.conversations.messages.draft -import androidx.lifecycle.SavedStateHandle import com.wire.android.R import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.config.mockUri import com.wire.android.framework.TestConversation import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.UIQuotedMessage import com.wire.android.ui.home.conversations.usecase.GetQuoteMessageForConversationUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.theme.Accent import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.message.draft.MessageDraft @@ -37,7 +34,6 @@ import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -47,7 +43,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class, SnapshotExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class MessageDraftViewModelTest { @Test @@ -169,14 +165,8 @@ class MessageDraftViewModelTest { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { - savedStateHandle.navArgs() - } returns ConversationNavArgs(conversationId = TestConversation.ID) } - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getMessageDraft: GetMessageDraftUseCase @@ -188,10 +178,10 @@ class MessageDraftViewModelTest { private val viewModel by lazy { MessageDraftViewModel( - savedStateHandle, - getMessageDraft, - getQuoteMessageForConversation, - saveMessageDraft, + getMessageDraft = getMessageDraft, + getQuotedMessage = getQuoteMessageForConversation, + saveMessageDraft = saveMessageDraft, + navigationArgs = ConversationNavArgs(TestConversation.ID), ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModelTest.kt index bfac6a4eb07..adde855543f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/migration/ConversationMigrationViewModelTest.kt @@ -17,14 +17,11 @@ */ package com.wire.android.ui.home.conversations.migration -import androidx.lifecycle.SavedStateHandle import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestConversation import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.ConversationNavArgs -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.type.UserType @@ -32,7 +29,6 @@ import com.wire.kalium.logic.data.user.type.UserTypeInfo import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -40,7 +36,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class ConversationMigrationViewModelTest { @Test @@ -79,12 +74,8 @@ class ConversationMigrationViewModelTest { @MockK lateinit var observeConversationDetailsUseCase: ObserveConversationDetailsUseCase - @MockK - lateinit var savedStateHandle: SavedStateHandle - init { MockKAnnotations.init(this) - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId) } fun withConversationDetailsReturning(conversationDetails: ConversationDetails) = apply { @@ -94,7 +85,10 @@ class ConversationMigrationViewModelTest { fun arrange(): Pair = run { configure() - this@Arrangement to ConversationMigrationViewModel(savedStateHandle, observeConversationDetailsUseCase) + this@Arrangement to ConversationMigrationViewModel( + observeConversationDetails = observeConversationDetailsUseCase, + navigationArgs = ConversationNavArgs(conversationId), + ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt index abaf71289ec..f8d3a4e74ea 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt @@ -17,11 +17,8 @@ */ package com.wire.android.ui.home.conversations.promoteadmin -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.mapper.testOtherUser import com.wire.kalium.logic.data.conversation.Conversation @@ -33,7 +30,6 @@ import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConve import com.wire.kalium.logic.feature.conversation.PromoteAdminAndLeaveConversationUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -47,7 +43,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class PromoteAdminViewModelTest { @Test @@ -297,9 +293,6 @@ class PromoteAdminViewModelTest { } private inner class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeEligibleMembers: ObserveEligibleMembersForConversationAdminRoleUseCase @@ -311,8 +304,6 @@ class PromoteAdminViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns - PromoteAdminNavArgs(ConversationId("conv1", "wire.com")) coEvery { observeEligibleMembers(any()) } returns flowOf(emptyList()) coEvery { observeConversationMembers(any()) } returns flowOf(emptyList()) coEvery { promoteAdminAndLeave(any(), any()) } returns PromoteAdminAndLeaveConversationUseCase.Result.Success @@ -326,9 +317,13 @@ class PromoteAdminViewModelTest { coEvery { observeConversationMembers(any()) } returns flowOf(members) } + private var navArgs = PromoteAdminNavArgs(ConversationId("conv1", "wire.com")) + fun withNavEligibleMembers(eligibleMembers: List) = apply { - every { savedStateHandle.navArgs() } returns - PromoteAdminNavArgs(ConversationId("conv1", "wire.com"), eligibleMembers.toPromoteAdminEligibleMemberArgs()) + navArgs = PromoteAdminNavArgs( + ConversationId("conv1", "wire.com"), + eligibleMembers.toPromoteAdminEligibleMemberArgs(), + ) } fun withPromoteAdminAndLeaveResult(result: PromoteAdminAndLeaveConversationUseCase.Result) = apply { @@ -340,7 +335,7 @@ class PromoteAdminViewModelTest { observeConversationMembers = observeConversationMembers, promoteAdminAndLeave = promoteAdminAndLeave, dispatchers = TestDispatcherProvider(), - savedStateHandle = savedStateHandle, + navArgs = navArgs, ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt index 5f15be1644d..f343538bf99 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/adddembertoconversation/AddMembersToConversationViewModelTest.kt @@ -17,15 +17,12 @@ */ package com.wire.android.ui.home.conversations.search.adddembertoconversation -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.model.UserAvatarData import com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.model.Contact -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.ConnectionState @@ -34,7 +31,6 @@ import com.wire.kalium.logic.feature.conversation.AddMemberToConversationUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.test.advanceUntilIdle @@ -44,7 +40,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class AddMembersToConversationViewModelTest { @Test @@ -154,9 +149,6 @@ class AddMembersToConversationViewModelTest { @MockK lateinit var addMemberToConversationUseCase: AddMemberToConversationUseCase - @MockK - lateinit var savedStateHandle: SavedStateHandle - val testDispatchers = TestDispatcherProvider() init { @@ -165,8 +157,10 @@ class AddMembersToConversationViewModelTest { lateinit var viewModel: AddMembersToConversationViewModel + private lateinit var navArgs: AddMembersSearchNavArgs + fun withAddMembersSearchNavArgs(navArgs: AddMembersSearchNavArgs) { - every { savedStateHandle.navArgs() } returns navArgs + this.navArgs = navArgs } fun withAddMemberToConversationUseCase(result: AddMemberToConversationUseCase.Result) { @@ -177,7 +171,7 @@ class AddMembersToConversationViewModelTest { viewModel = AddMembersToConversationViewModel( addMemberToConversation = addMemberToConversationUseCase, dispatchers = testDispatchers, - savedStateHandle = savedStateHandle + addMembersSearchNavArgs = navArgs, ) this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt index 7efed61dc5f..99dbf7efe53 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.conversations.sendmessage -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.feature.analytics.AnonymousAnalyticsManager @@ -28,7 +27,6 @@ import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.MessageSharedState import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.ImageUtil import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.data.id.ConversationId @@ -69,14 +67,12 @@ import okio.buffer internal class SendMessageViewModelArrangement { val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + private var navigationArgs = ConversationNavArgs(conversationId) init { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns ConversationNavArgs( - conversationId = conversationId - ) // Default empty values coEvery { observeOngoingCallsUseCase() } returns flowOf(listOf()) coEvery { observeEstablishedCallsUseCase() } returns flowOf(listOf()) @@ -102,9 +98,6 @@ internal class SendMessageViewModelArrangement { } } - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var sendTextMessage: SendTextMessageUseCase @@ -202,7 +195,7 @@ internal class SendMessageViewModelArrangement { observeConversationUnderLegalHoldNotified = observeConversationUnderLegalHoldNotified, sendLocation = sendLocation, removeMessageDraft = removeMessageDraftUseCase, - savedStateHandle = savedStateHandle, + navigationArgs = navigationArgs, analyticsManager = analyticsManager, sendMultipartMessage = sendMultipartMessage, isWireCellsEnabledForConversation = isWireCellsEnabledForConversation, @@ -305,14 +298,14 @@ internal class SendMessageViewModelArrangement { } fun withPendingTextBundle(textToShare: String = "some text") = apply { - every { savedStateHandle.navArgs() } returns ConversationNavArgs( + navigationArgs = ConversationNavArgs( conversationId = conversationId, pendingTextBundle = textToShare ) } fun withPendingAssetBundle(vararg assetBundle: AssetBundle) = apply { - every { savedStateHandle.navArgs() } returns ConversationNavArgs( + navigationArgs = ConversationNavArgs( conversationId = conversationId, pendingBundles = arrayListOf(*assetBundle) ) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index 30fadeb4204..152ff38d7c6 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -22,7 +22,6 @@ import android.location.Location import androidx.core.net.toUri import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.ui.home.conversations.AssetTooLargeDialogState import com.wire.android.ui.home.conversations.ConversationSnackbarMessages @@ -58,7 +57,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) @Suppress("LargeClass") class SendMessageViewModelTest { private val testDispatcher = StandardTestDispatcher() diff --git a/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt index 11b80c8d58b..ec6d36c99eb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/drawer/HomeDrawerViewModelTest.kt @@ -17,9 +17,7 @@ */ package com.wire.android.ui.home.drawer -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestUser import com.wire.android.navigation.HomeDestination import com.wire.kalium.logic.data.user.type.UserType @@ -45,7 +43,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) @OptIn(ExperimentalCoroutinesApi::class) class HomeDrawerViewModelTest { @@ -165,9 +162,6 @@ class HomeDrawerViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var observeArchivedUnreadConversationsCount: ObserveArchivedUnreadConversationsCountUseCase @@ -207,7 +201,6 @@ class HomeDrawerViewModelTest { } fun arrange() = this to HomeDrawerViewModel( - savedStateHandle = savedStateHandle, observeArchivedUnreadConversationsCount = lazyOf(observeArchivedUnreadConversationsCount), observeSelfUser = observeSelfUserUseCase, getTeamUrl = getTeamUrlUseCase, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt index fdac117fca3..ae4c08972be 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt @@ -18,16 +18,13 @@ package com.wire.android.ui.home.gallery -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.ui.home.conversations.MediaGallerySnackbarMessages import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogType -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.FileManager import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetMessageAttachmentUseCase @@ -55,7 +52,6 @@ import com.wire.kalium.logic.feature.message.MessageOperationResult import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -73,7 +69,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class MediaGalleryViewModelTest { @Test @@ -418,9 +413,6 @@ class MediaGalleryViewModelTest { } private class Arrangement { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getConversationDetails: ObserveConversationDetailsUseCase @@ -438,6 +430,14 @@ class MediaGalleryViewModelTest { @MockK lateinit var getCellFile: GetCellFileUseCase + private var navigationArgs = MediaGalleryNavArgs( + dummyConversationId, + dummyMessageId, + true, + false, + true, + null + ) @MockK lateinit var isSelfUserViewerOnConversation: IsSelfUserViewerOnConversationUseCase @@ -447,27 +447,12 @@ class MediaGalleryViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) coEvery { isSelfUserViewerOnConversation(any()) } returns true - - every { savedStateHandle.navArgs() } returns MediaGalleryNavArgs( - conversationId = dummyConversationId, - messageId = dummyMessageId, - isSelfAsset = true, - isEphemeral = false, - messageOptionsEnabled = true, - cellAssetId = null, - ) - coEvery { deleteMessage(any(), any(), any()) } returns MessageOperationResult.Success } fun withNavArgs(messageOptionsEnabled: Boolean = true, isEphemeral: Boolean = false, cellAssetId: String? = null) = apply { - every { savedStateHandle.navArgs() } returns MediaGalleryNavArgs( - conversationId = dummyConversationId, - messageId = dummyMessageId, - isSelfAsset = true, - isEphemeral = isEphemeral, - messageOptionsEnabled = messageOptionsEnabled, - cellAssetId = cellAssetId, + navigationArgs = MediaGalleryNavArgs( + dummyConversationId, dummyMessageId, true, isEphemeral, messageOptionsEnabled, cellAssetId ) } @@ -526,7 +511,7 @@ class MediaGalleryViewModelTest { } fun arrange() = this to MediaGalleryViewModel( - savedStateHandle, + navigationArgs, getConversationDetails, TestDispatcherProvider(), getImageData, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Test.kt new file mode 100644 index 00000000000..758e36668ac --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationNavigation3Test.kt @@ -0,0 +1,171 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.newconversation + +import java.io.File +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class NewConversationNavigation3Test { + + @Test + fun givenNewConversationStartFactory_whenCreatingRoute_thenEntryAndFlowOwnershipMatch() { + val route = NewConversationSearchPeopleRoute.start(sessionId) + + assertEquals(route.entryId.value, route.flowId) + } + + private val sessionId = WireSessionId("user", "wire.example") + private val flowId = "new-conversation-42" + + @Test + fun givenNewConversationRoutes_whenSerializedAndRestored_thenScopeAndIdentityArePreserved() { + assertSerializationRoundTrip( + NewConversationSearchPeopleRoute( + sessionId, + flowId, + WireNavEntryId("search-entry"), + ) + ) + assertSerializationRoundTrip( + NewGroupConversationSearchPeopleRoute( + sessionId, + flowId, + WireNavEntryId("group-search-entry"), + ) + ) + assertSerializationRoundTrip( + NewGroupNameRoute( + sessionId, + flowId, + WireNavEntryId("group-name-entry"), + ) + ) + assertSerializationRoundTrip( + GroupOptionRoute( + sessionId, + flowId, + WireNavEntryId("group-option-entry"), + ) + ) + assertSerializationRoundTrip( + ChannelAccessOnCreateRoute( + sessionId, + flowId, + WireNavEntryId("channel-access-entry"), + ) + ) + } + + @Test + fun givenNewConversationRoutes_whenReadingRouteIds_thenStableRouteIdsArePreserved() { + val expectations = mapOf( + NewConversationSearchPeopleRoute(sessionId, flowId) to + NewConversationSearchPeopleRoute.ROUTE_ID, + NewGroupConversationSearchPeopleRoute(sessionId, flowId) to + NewGroupConversationSearchPeopleRoute.ROUTE_ID, + NewGroupNameRoute(sessionId, flowId) to NewGroupNameRoute.ROUTE_ID, + GroupOptionRoute(sessionId, flowId) to GroupOptionRoute.ROUTE_ID, + ChannelAccessOnCreateRoute(sessionId, flowId) to + ChannelAccessOnCreateRoute.ROUTE_ID, + ) + + expectations.forEach { (route, expectedRouteId) -> + assertEquals(expectedRouteId, route.routeId) + } + } + + @Test + fun givenEveryStepOfOneFlow_whenReadingScope_thenSessionAndFlowAreIdentical() { + val routes = allRoutes() + + assertEquals(setOf(sessionId), routes.map { it.sessionId }.toSet()) + assertEquals(setOf(flowId), routes.map { it.flowId }.toSet()) + } + + @Test + fun givenSameDestinationAndScope_whenCreatingTwoEntries_thenEntryIdentityIsUnique() { + val first = GroupOptionRoute(sessionId, flowId) + val second = GroupOptionRoute(sessionId, flowId) + + assertNotEquals(first.entryId, second.entryId) + assertEquals(first.routeId, second.routeId) + assertEquals(first.flowId, second.flowId) + } + + @Test + fun givenBlankFlowId_whenCreatingAnyNewConversationRoute_thenItIsRejected() { + val factories: List<() -> NewConversationRoute> = listOf( + { NewConversationSearchPeopleRoute(sessionId, " ") }, + { NewGroupConversationSearchPeopleRoute(sessionId, " ") }, + { NewGroupNameRoute(sessionId, " ") }, + { GroupOptionRoute(sessionId, " ") }, + { ChannelAccessOnCreateRoute(sessionId, " ") }, + ) + + factories.forEach { factory -> + assertThrows(IllegalArgumentException::class.java) { + factory() + } + } + } + + @Test + fun givenNewConversationStartEntry_whenRegistered_thenItRetainsBottomUpPresentation() { + val source = sourceFile("NewConversationNavigation3Entries.kt").readText() + + assertTrue( + "wireEntry(" in source && + "presentation = WireEntryPresentation.PopUp" in source + ) + } + + private fun allRoutes(): List = listOf( + NewConversationSearchPeopleRoute(sessionId, flowId), + NewGroupConversationSearchPeopleRoute(sessionId, flowId), + NewGroupNameRoute(sessionId, flowId), + GroupOptionRoute(sessionId, flowId), + ChannelAccessOnCreateRoute(sessionId, flowId), + ) + + private inline fun assertSerializationRoundTrip( + route: T, + ) { + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals(sessionId, restored.sessionId) + assertEquals(flowId, restored.flowId) + assertEquals(route.entryId, restored.entryId) + assertEquals(route.routeId, restored.routeId) + } + + private fun sourceFile(name: String): File { + val root = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(root, "app/src/main/kotlin/com/wire/android/ui/home/newconversation/$name") + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Test.kt new file mode 100644 index 00000000000..0ab29b3bd75 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryNavigation3Test.kt @@ -0,0 +1,167 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.newconversation.channelhistory + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class ChannelHistoryNavigation3Test { + + @Test + fun givenChannelHistoryCustomResultContract_whenAddressed_thenItsIdIsStable() { + assertEquals( + "new-conversation.channel-history.custom", + ChannelHistoryCustomResultContract.id.value, + ) + } + + @Test + fun givenChannelHistoryPilotContribution_whenReadByHost_thenResultTypeIsRegisteredOnce() { + assertEquals( + listOf(ChannelHistoryCustomResultType), + ChannelHistoryNavigation3Pilot.resultTypes, + ) + } + + @Test + fun givenPositiveCustomResult_whenSerializedAndRestored_thenValueIsPreserved() { + val result = ChannelHistoryCustomResult( + ChannelHistorySelection.Specific(9, ChannelHistorySelection.AmountUnit.WEEKS) + ) + + assertEquals( + result, + Json.decodeFromString(Json.encodeToString(result)), + ) + } + + @Test + fun givenCustomRoute_whenSerializedAndRestored_thenTypedArgumentsAndIdentityArePreserved() { + val route = ChannelHistoryCustomRoute( + sessionId = WireSessionId("user", "wire.example"), + flowId = "new-conversation-42", + currentType = ChannelHistorySelection.Specific( + amount = 6, + unit = ChannelHistorySelection.AmountUnit.MONTHS, + ), + entryId = WireNavEntryId("channel-history-custom-entry"), + ) + + val restored = Json.decodeFromString( + Json.encodeToString(route) + ) + + assertEquals(route, restored) + assertEquals("user", restored.sessionId.value) + assertEquals("wire.example", restored.sessionId.domain) + assertEquals("new-conversation-42", restored.flowId) + assertEquals( + ChannelHistorySelection.Specific(6, ChannelHistorySelection.AmountUnit.MONTHS), + restored.currentType, + ) + assertEquals("channel-history-custom-entry", restored.entryId.value) + } + + @Test + fun givenSameRouteArguments_whenCreatingTwoEntries_thenTheirEntryIdentityIsDifferent() { + val first = ChannelHistoryRoute( + sessionId = WireSessionId("user", "wire.example"), + flowId = "new-conversation-42", + ) + val second = ChannelHistoryRoute( + sessionId = WireSessionId("user", "wire.example"), + flowId = "new-conversation-42", + ) + + assertNotEquals(first.entryId, second.entryId) + assertEquals(first.routeId, second.routeId) + assertEquals("app/channel_history_screen", first.routeId) + } + + @Test + fun givenEveryLegacyHistoryType_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + val values = listOf( + ChannelHistoryType.Off, + ChannelHistoryType.On.Unlimited, + ChannelHistoryType.On.Specific( + 1, + ChannelHistoryType.On.Specific.AmountType.Days, + ), + ChannelHistoryType.On.Specific( + 2, + ChannelHistoryType.On.Specific.AmountType.Weeks, + ), + ChannelHistoryType.On.Specific( + 3, + ChannelHistoryType.On.Specific.AmountType.Months, + ), + ) + + values.forEach { value -> + assertEquals(value, value.toSelection().toLegacy()) + } + } + + @Test + fun givenPositiveCustomAmount_whenMappingResult_thenSpecificHistoryTypeIsReturned() { + assertEquals( + ChannelHistoryType.On.Specific( + amount = 12, + type = ChannelHistoryType.On.Specific.AmountType.Weeks, + ), + channelHistorySpecificOrNull( + amount = "12", + type = ChannelHistoryType.On.Specific.AmountType.Weeks, + ), + ) + } + + @Test + fun givenInvalidCustomAmount_whenMappingResult_thenCancellationValueIsReturned() { + assertNull( + channelHistorySpecificOrNull( + amount = "", + type = ChannelHistoryType.On.Specific.AmountType.Days, + ) + ) + assertNull( + channelHistorySpecificOrNull( + amount = "0", + type = ChannelHistoryType.On.Specific.AmountType.Days, + ) + ) + } + + @Test + fun givenNonPositiveAmount_whenCreatingTypedSelection_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + ChannelHistorySelection.Specific( + amount = 0, + unit = ChannelHistorySelection.AmountUnit.DAYS, + ) + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3EntriesSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3EntriesSourceTest.kt new file mode 100644 index 00000000000..05cf399e3b3 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsAccountNavigation3EntriesSourceTest.kt @@ -0,0 +1,95 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.settings + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SettingsAccountNavigation3EntriesSourceTest { + + @Test + fun givenSettingsAccountContribution_whenInspectingSource_thenAllTypedEntriesAndResultAreRegistered() { + val entries = sourceFile("SettingsAccountNavigation3Entries.kt").readText() + + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + ).forEach { registration -> + assertTrue(entries.contains(registration), "Missing $registration") + } + assertTrue(entries.contains("SettingsAccountUpdateNavigation3ResultType")) + + val contribution = sourceFile("SettingsNavigation3Entries.kt").readText() + assertTrue(contribution.contains("settingsAccountNavigation3Entries(runtime, actions)")) + assertTrue(contribution.contains("listOf(SettingsAccountUpdateNavigation3ResultType)")) + } + + @Test + fun givenSettingsAccountEntries_whenInspectingSource_thenNoLegacyArgumentBridgeIsUsed() { + val entries = sourceFile("SettingsAccountNavigation3Entries.kt").readText() + + listOf( + "com.ramcosta.composedestinations", + "ScreenDestination", + "SavedStateHandle", + "Bundle", + "DEFAULT_ARGS_KEY", + ".navArgs()", + ).forEach { forbidden -> + assertFalse(entries.contains(forbidden), "Entries must not reference $forbidden") + } + assertTrue(entries.contains("verifyEmailViewModel(route.toViewModelArgs())")) + assertTrue(entries.contains("navigateForResult(")) + assertTrue(entries.contains("completeCurrentAndPop(")) + } + + @Test + fun givenSettingsRouteContract_whenInspectingImports_thenItRemainsKmpSourcePure() { + val source = sourceFile("SettingsNavigation3.kt").readText() + listOf("android.", "androidx.", "com.ramcosta.", "com.wire.kalium.").forEach { prefix -> + assertFalse( + source.lineSequence() + .filter { it.startsWith("import ") } + .any { it.removePrefix("import ").startsWith(prefix) }, + "SettingsNavigation3.kt must not import $prefix", + ) + } + } + + @Test + fun givenEmailUpdateCompletes_whenInspectingScreen_thenTerminalNavigationRunsAsAnEffect() { + val source = sourceFile( + "account/email/updateEmail/ChangeEmailScreen.kt" + ).readText() + + assertTrue(source.contains("LaunchedEffect(flowState)")) + assertTrue(source.contains("onVerifyEmail(flowState.newEmail)")) + } + + private fun sourceFile(relativePath: String): File { + val userDir = requireNotNull(System.getProperty("user.dir")) + val projectDir = generateSequence(File(userDir)) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + projectDir, + "app/src/main/kotlin/com/wire/android/ui/home/settings/$relativePath", + ).also { + assertTrue(it.isFile, "Missing source file $relativePath") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Test.kt new file mode 100644 index 00000000000..2ba71a524ea --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/SettingsNavigation3Test.kt @@ -0,0 +1,213 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.settings + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class SettingsNavigation3Test { + + private val sessionId = WireSessionId("user", "wire.example") + + @Test + fun givenSettingsRoutes_whenSerializedAndRestored_thenSessionAndEntryIdentityArePreserved() { + assertSerializationRoundTrip( + SettingsRoute(sessionId, WireNavEntryId("settings-entry")), + ) + assertSerializationRoundTrip( + AppSettingsRoute(sessionId, WireNavEntryId("app-settings-entry")), + ) + assertSerializationRoundTrip( + NetworkSettingsRoute(sessionId, WireNavEntryId("network-settings-entry")), + ) + assertSerializationRoundTrip( + PrivacySettingsRoute(sessionId, WireNavEntryId("privacy-settings-entry")), + ) + assertSerializationRoundTrip( + CustomizationSettingsRoute(sessionId, WireNavEntryId("customization-settings-entry")), + ) + assertSerializationRoundTrip( + LicensesSettingsRoute(sessionId, WireNavEntryId("licenses-settings-entry")), + ) + assertSerializationRoundTrip( + DependenciesSettingsRoute(sessionId, WireNavEntryId("dependencies-settings-entry")), + ) + assertSerializationRoundTrip( + BackupAndRestoreSettingsRoute(sessionId, WireNavEntryId("backup-settings-entry")), + ) + assertSerializationRoundTrip( + AboutThisAppRoute(sessionId, WireNavEntryId("about-entry")), + ) + assertSerializationRoundTrip( + MyAccountRoute(sessionId, WireNavEntryId("account-entry")), + ) + assertSerializationRoundTrip( + ChangeEmailRoute(sessionId, WireNavEntryId("change-email-entry")), + ) + assertSerializationRoundTrip( + VerifyEmailRoute(sessionId, "alice@example.com", WireNavEntryId("verify-email-entry")), + ) + assertSerializationRoundTrip( + ChangeUserColorRoute(sessionId, WireNavEntryId("color-entry")), + ) + assertSerializationRoundTrip( + ChangeHandleRoute(sessionId, WireNavEntryId("handle-entry")), + ) + assertSerializationRoundTrip( + ChangeDisplayNameRoute(sessionId, WireNavEntryId("display-name-entry")), + ) + } + + @Test + fun givenSameSettingsDestination_whenCreatingTwoEntries_thenEntryIdentityIsUniqueAndRouteIdIsStable() { + val first = PrivacySettingsRoute(sessionId) + val second = PrivacySettingsRoute(sessionId) + + assertNotEquals(first.entryId, second.entryId) + assertEquals(PrivacySettingsRoute.ROUTE_ID, first.routeId) + assertEquals(first.routeId, second.routeId) + } + + @Test + fun givenSettingsRoutes_whenReadingRouteIds_thenLegacyAnalyticsBaseRoutesArePreserved() { + val expectations = mapOf( + SettingsRoute(sessionId) to "app/settings_screen", + AppSettingsRoute(sessionId) to "app/app_settings_screen", + NetworkSettingsRoute(sessionId) to "app/network_settings_screen", + PrivacySettingsRoute(sessionId) to "app/privacy_settings_config_screen", + CustomizationSettingsRoute(sessionId) to "app/customization_screen", + LicensesSettingsRoute(sessionId) to "app/licenses_screen", + DependenciesSettingsRoute(sessionId) to "app/dependencies_screen", + BackupAndRestoreSettingsRoute(sessionId) to "app/backup_and_restore_screen", + AboutThisAppRoute(sessionId) to "app/about_this_app_screen", + MyAccountRoute(sessionId) to "app/my_account_screen", + ChangeEmailRoute(sessionId) to "app/change_email_screen", + VerifyEmailRoute(sessionId, "alice@example.com") to "app/verify_email_screen", + ChangeUserColorRoute(sessionId) to "app/change_user_color_screen", + ChangeHandleRoute(sessionId) to "app/change_handle_screen", + ChangeDisplayNameRoute(sessionId) to "app/change_display_name_screen", + ) + + expectations.forEach { (route, expectedRouteId) -> + assertEquals(expectedRouteId, route.routeId) + } + } + + @Test + fun givenMigratedSettingsItems_whenMappingToNavigation3_thenExpectedTypedRoutesAreReturned() { + val entryId = WireNavEntryId("mapped-settings-entry") + val expectations = listOf( + SettingsItem.AppSettings to AppSettingsRoute(sessionId, entryId), + SettingsItem.YourAccount to MyAccountRoute(sessionId, entryId), + SettingsItem.AboutApp to AboutThisAppRoute(sessionId, entryId), + SettingsItem.NetworkSettings to NetworkSettingsRoute(sessionId, entryId), + SettingsItem.PrivacySettings to PrivacySettingsRoute(sessionId, entryId), + SettingsItem.Customization to CustomizationSettingsRoute(sessionId, entryId), + SettingsItem.Licenses to LicensesSettingsRoute(sessionId, entryId), + SettingsItem.Dependencies to DependenciesSettingsRoute(sessionId, entryId), + SettingsItem.BackupAndRestore to BackupAndRestoreSettingsRoute(sessionId, entryId), + ) + + expectations.forEach { (item, route) -> + assertEquals(route, item.toNavigation3Route(sessionId, entryId)) + } + } + + @Test + fun givenSettingsItemOutsideMigratedBatches_whenMappingToNavigation3_thenNoRouteIsClaimed() { + assertNull(SettingsItem.ManageDevices.toNavigation3Route(sessionId)) + } + + @Test + fun givenEverySettingsDirection_whenResolvingNavigation3Target_thenTypedOrExternalTargetIsReturned() { + val typedItems = listOf( + SettingsItem.AppSettings, + SettingsItem.YourAccount, + SettingsItem.AboutApp, + SettingsItem.NetworkSettings, + SettingsItem.PrivacySettings, + SettingsItem.Customization, + SettingsItem.Licenses, + SettingsItem.Dependencies, + SettingsItem.BackupAndRestore, + ) + typedItems.forEach { item -> + val target = item.toNavigation3Target(sessionId) + + assertEquals( + item.toNavigation3Route(sessionId)?.routeId, + (target as SettingsNavigation3Target.Route).route.routeId, + ) + } + + val externalItems = mapOf( + SettingsItem.ManageDevices to SettingsNavigation3Destination.MANAGE_DEVICES, + SettingsItem.TermsOfUse to SettingsNavigation3Destination.TERMS_OF_USE, + SettingsItem.WireWebsite to SettingsNavigation3Destination.WIRE_WEBSITE, + SettingsItem.PrivacyPolicy to SettingsNavigation3Destination.PRIVACY_POLICY, + SettingsItem.Support to SettingsNavigation3Destination.SUPPORT, + SettingsItem.ReportMisuse to SettingsNavigation3Destination.REPORT_MISUSE, + SettingsItem.DebugSettings to SettingsNavigation3Destination.DEBUG_SETTINGS, + SettingsItem.GiveFeedback to SettingsNavigation3Destination.GIVE_FEEDBACK, + ) + externalItems.forEach { (item, destination) -> + assertEquals( + SettingsNavigation3Target.External(destination), + item.toNavigation3Target(sessionId), + ) + } + } + + @Test + fun givenBlankEmail_whenCreatingVerifyEmailRoute_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + VerifyEmailRoute(sessionId, "") + } + } + + @Test + fun givenAccountUpdateResult_whenSerializedAndRestored_thenSuccessValueIsPreserved() { + val result = SettingsAccountUpdateResult(successful = false) + + assertEquals( + result, + Json.decodeFromString(Json.encodeToString(result)), + ) + assertEquals("settings.account-update", SettingsAccountUpdateResultContract.id.value) + } + + private inline fun assertSerializationRoundTrip( + route: T, + ) { + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals(sessionId, restored.sessionId) + assertEquals(route.entryId, restored.entryId) + assertEquals(route.routeId, restored.routeId) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/VerifyEmailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/VerifyEmailViewModelTest.kt index 05b7c686138..46900e0e76c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/VerifyEmailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/VerifyEmailViewModelTest.kt @@ -17,17 +17,13 @@ */ package com.wire.android.ui.home.settings.account.email -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension -import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailNavArgs import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel -import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModelArgs import com.wire.kalium.logic.feature.user.UpdateEmailUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -36,9 +32,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(NavigationTestExtension::class) class VerifyEmailViewModelTest { @Test @@ -58,9 +52,6 @@ class VerifyEmailViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var updateEmail: UpdateEmailUseCase @@ -68,17 +59,17 @@ class VerifyEmailViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) } - fun withNewEmail(email: String) = apply { - every { savedStateHandle.navArgs() } returns VerifyEmailNavArgs(newEmail = email) - } + private var newEmail = "" + + fun withNewEmail(email: String) = apply { newEmail = email } fun withUpdateEmailResult(result: UpdateEmailUseCase.Result) = apply { coEvery { updateEmail(any()) } returns result } fun arrange() = this to VerifyEmailViewModel( - updateEmail, - savedStateHandle + updateEmail = updateEmail, + navigationArgs = VerifyEmailViewModelArgs(newEmail), ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt index 40a3d1dc20c..624372ad0cc 100644 --- a/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/newauthentication/login/NewLoginViewModelTest.kt @@ -3,9 +3,7 @@ package com.wire.android.ui.newauthentication.login import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.ServerConfigProvider import com.wire.android.config.SnapshotExtension import com.wire.android.config.TestDispatcherProvider @@ -19,7 +17,6 @@ import com.wire.android.ui.authentication.login.LoginViewModelExtension import com.wire.android.ui.authentication.login.PreFilledUserIdentifierType import com.wire.android.ui.authentication.login.SSOCodeAutoLogin import com.wire.android.ui.authentication.login.sso.LoginSSOViewModelExtension -import com.wire.android.ui.authentication.login.sso.ReplaceRetainedSsoSessionResult import com.wire.android.ui.authentication.login.sso.SSOUrlConfig import com.wire.android.ui.newauthentication.login.ValidateEmailOrSSOCodeUseCase.Result.ValidEmail import com.wire.android.util.EMPTY @@ -32,7 +29,6 @@ import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.logout.LogoutReason -import com.wire.kalium.logic.data.session.StoreSessionParam import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.AuthenticationScope @@ -74,7 +70,7 @@ import org.junit.jupiter.api.extension.ExtendWith @Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class NewLoginViewModelTest { private val dispatchers = TestDispatcherProvider() @@ -356,68 +352,6 @@ class NewLoginViewModelTest { } } - @Test - fun `given retained account has different SSO identity, when handling SSO result, then show confirmation`() = - runTest(dispatchers.main()) { - val pendingSession = mockk() - every { pendingSession.nomadServiceUrl } returns null - val (_, viewModel) = Arrangement() - .withEstablishSSOSessionIdentityChanged(pendingSession) - .arrange() - - viewModel.handleSSOResult(DeepLinkResult.SSOLogin.Success("cookie", "server-config-id")) - advanceUntilIdle() - - assertEquals(NewLoginFlowState.SsoIdentityChanged, viewModel.state.flowState) - } - - @Test - fun `given SSO identity change confirmation, when dismissed, then retained account is not replaced`() = - runTest(dispatchers.main()) { - val pendingSession = mockk() - every { pendingSession.nomadServiceUrl } returns null - val (arrangement, viewModel) = Arrangement() - .withEstablishSSOSessionIdentityChanged(pendingSession) - .arrange() - - viewModel.handleSSOResult(DeepLinkResult.SSOLogin.Success("cookie", "server-config-id")) - advanceUntilIdle() - viewModel.onSsoIdentityChangeDismissed() - viewModel.onSsoIdentityChangeConfirmed() - advanceUntilIdle() - - assertEquals(NewLoginFlowState.Default, viewModel.state.flowState) - coVerify(exactly = 0) { - arrangement.loginSSOViewModelExtension.replaceRetainedSsoSession(any()) - } - } - - @Test - fun `given SSO identity change confirmation, when confirmed, then replace account and continue login`() = - runTest(dispatchers.main()) { - val pendingSession = mockk() - every { pendingSession.nomadServiceUrl } returns null - val userId = UserId("user-id", "domain") - val (arrangement, viewModel) = Arrangement() - .withEstablishSSOSessionIdentityChanged(pendingSession) - .withReplaceRetainedSsoSessionReturning(ReplaceRetainedSsoSessionResult.Success(userId)) - .withRegisterClientReturning(RegisterClientResult.Success(TestClient.CLIENT)) - .withIsInitialSyncCompletedReturning(true) - .arrange() - - viewModel.handleSSOResult(DeepLinkResult.SSOLogin.Success("cookie", "server-config-id")) - advanceUntilIdle() - viewModel.onSsoIdentityChangeConfirmed() - advanceUntilIdle() - - coVerify(exactly = 1) { - arrangement.loginSSOViewModelExtension.replaceRetainedSsoSession(pendingSession) - } - coVerify(exactly = 1) { - arrangement.loginViewModelExtension.registerClient(userId, any(), any(), any()) - } - } - private fun testHandleSSOResultRegisterClientResults( result: RegisterClientResult, expectedNextStep: NewLoginAction.Success.NextStep, @@ -444,7 +378,7 @@ class NewLoginViewModelTest { testHandleSSOResultRegisterClientResults( result = RegisterClientResult.Success(TestClient.CLIENT), isInitialSyncCompleted = true, - expectedNextStep = NewLoginAction.Success.NextStep.None, + expectedNextStep = NewLoginAction.Success.NextStep.None(UserId("user-id", "domain")), ) @Test @@ -452,7 +386,7 @@ class NewLoginViewModelTest { testHandleSSOResultRegisterClientResults( result = RegisterClientResult.Success(TestClient.CLIENT), isInitialSyncCompleted = false, - expectedNextStep = NewLoginAction.Success.NextStep.InitialSync, + expectedNextStep = NewLoginAction.Success.NextStep.InitialSync(UserId("user-id", "domain")), ) @Test @@ -533,7 +467,7 @@ class NewLoginViewModelTest { any(), any(), any(), - any(), + any() ) } assertEquals(nomadServiceUrl, consumeNomadServiceUrlProviders[0]()) @@ -593,6 +527,33 @@ class NewLoginViewModelTest { ), ) + @Test + fun givenReusedLoginFlow_whenNavigationBackendChanges_thenViewModelUsesNewBackend() = runTest(dispatchers.main()) { + val (_, viewModel) = Arrangement().arrange() + val updatedServerConfig = newServerConfig(2).links + + viewModel.onNavigationArgumentsChanged( + LoginNavArgs( + loginPasswordPath = LoginPasswordPath(updatedServerConfig), + showBackendConfigSuccess = true, + ) + ) + + assertEquals(updatedServerConfig, viewModel.serverConfig) + assertEquals(NewLoginFlowState.BackendConfigSuccess, viewModel.state.flowState) + } + + @Test + fun givenReusedLoginFlow_whenNavigationPrefillChanges_thenIdentifierIsUpdated() = runTest(dispatchers.main()) { + val (_, viewModel) = Arrangement().arrange() + + viewModel.onNavigationArgumentsChanged( + LoginNavArgs(userHandle = PreFilledUserIdentifierType.PreFilled(email)) + ) + + assertEquals(email, viewModel.userIdentifierTextState.text.toString()) + } + @Test fun `given no registration path, when enterprise login, then call EmailPassword action with no creation`() = testEnterpriseLoginActions( @@ -660,49 +621,6 @@ class NewLoginViewModelTest { } } - @Test - fun `given multi-ingress SSO, when login callback succeeds, then establish session with selected IdP ID`() = - runTest(dispatchers.main()) { - val redirectUrl = "https://redirect.url" - val cookie = "cookie" - val serverConfigId = "server-config-id" - val (arrangement, viewModel) = Arrangement() - .withAuthenticationScopeSuccess() - .withGetLoginFlowForDomainReturning( - EnterpriseLoginResult.Success(LoginRedirectPath.SSO(SSO_CODE_WITHOUT_PREFIX)) - ) - .withInitiateSSOSuccess(redirectUrl) - .withEstablishSSOSessionAddUserFailure(AddAuthenticatedUserUseCase.Result.Failure.UserAlreadyExists) - .arrange() - - viewModel.actions.test { - viewModel.getEnterpriseLoginFlow(email) - advanceUntilIdle() - assertEquals( - NewLoginAction.SSO(redirectUrl, SSOUrlConfig(viewModel.userIdentifierTextState.text.toString())), - awaitItem() - ) - - viewModel.handleSSOResult(DeepLinkResult.SSOLogin.Success(cookie, serverConfigId)) - advanceUntilIdle() - } - - coVerify(exactly = 1) { - arrangement.loginSSOViewModelExtension.establishSSOSession( - eq(cookie), - eq(serverConfigId), - any(), - any(), - any(), - any(), - any(), - any(), - any(), - eq(SSO_CODE_WITHOUT_PREFIX), - ) - } - } - @Test fun `given custom backend path, when enterprise login, then update custom backend state`() = runTest(dispatchers.main()) { val customServerConfig: ServerConfig.Links = newServerConfig(2).links @@ -765,6 +683,8 @@ class NewLoginViewModelTest { @MockK private lateinit var savedStateHandle: SavedStateHandle + private var loginNavArgs = LoginNavArgs() + @MockK private lateinit var clientScopeProviderFactory: ClientScopeProvider.Factory @@ -794,36 +714,23 @@ class NewLoginViewModelTest { @MockK lateinit var getServerConfigUseCase: GetServerConfigUseCase - private val savedState = mutableMapOf() - init { MockKAnnotations.init(this, relaxUnitFun = true) every { savedStateHandle.get(any()) - } answers { - savedState[firstArg()] - } + } returns null every { savedStateHandle[any()] = any() - } answers { - savedState[firstArg()] = secondArg() - } + } returns Unit every { savedStateHandle.remove(any()) - } answers { - savedState.remove(firstArg()) - } - every { - savedStateHandle.navArgs() - } returns LoginNavArgs() + } returns null every { coreLogic.getGlobalScope().deleteSession } returns deleteSessionUseCase every { coreLogic.getSessionScope(any()).logout } returns logoutUseCase } fun withNavArgsServerConfig(serverConfig: ServerConfig.Links) = apply { - every { - savedStateHandle.navArgs() - } returns LoginNavArgs(loginPasswordPath = LoginPasswordPath(serverConfig)) + loginNavArgs = LoginNavArgs(loginPasswordPath = LoginPasswordPath(serverConfig)) } fun withEmailOrSSOCodeValidatorReturning(result: ValidateEmailOrSSOCodeUseCase.Result = ValidEmail) = apply { @@ -954,26 +861,8 @@ class NewLoginViewModelTest { } } - fun withEstablishSSOSessionIdentityChanged(session: StoreSessionParam) = apply { - coEvery { - loginSSOViewModelExtension.establishSSOSession( - any(), any(), any(), any(), any(), any(), any(), any(), any(), any() - ) - } coAnswers { - arg Unit>(8)(session) - } - } - - fun withReplaceRetainedSsoSessionReturning(result: ReplaceRetainedSsoSessionResult) = apply { - coEvery { - loginSSOViewModelExtension.replaceRetainedSsoSession(any()) - } returns result - } - fun withNomadAutoLogin(nomadServiceUrl: String) = apply { - every { - savedStateHandle.navArgs() - } returns LoginNavArgs( + loginNavArgs = LoginNavArgs( ssoCodeAutoLogin = SSOCodeAutoLogin( ssoCode = "wire-sso-code", nomadServiceUrl = nomadServiceUrl, @@ -986,9 +875,7 @@ class NewLoginViewModelTest { every { savedStateHandle.get(any()) } returns null - every { - savedStateHandle.navArgs() - } returns LoginNavArgs() + loginNavArgs = LoginNavArgs() } fun withUserIdentifierAlreadySet(userIdentifier: String) = apply { @@ -998,9 +885,7 @@ class NewLoginViewModelTest { } fun withPreFilledUserIdentifier(userIdentifier: String) = apply { - every { - savedStateHandle.navArgs() - } returns LoginNavArgs(userHandle = PreFilledUserIdentifierType.PreFilled(userIdentifier)) + loginNavArgs = LoginNavArgs(userHandle = PreFilledUserIdentifierType.PreFilled(userIdentifier)) } fun withFetchDefaultSSOCodeSuccessAfterDelay(defaultSSOCode: String?) = apply { @@ -1018,9 +903,7 @@ class NewLoginViewModelTest { } fun withCustomServerConfigDeepLink() = apply { - every { - savedStateHandle.navArgs() - } returns LoginNavArgs( + loginNavArgs = LoginNavArgs( loginPasswordPath = LoginPasswordPath( customServerConfig = ServerConfig.STAGING ) @@ -1028,9 +911,7 @@ class NewLoginViewModelTest { } fun withEmptyCustomServerConfig() = apply { - every { - savedStateHandle.navArgs() - } returns LoginNavArgs( + loginNavArgs = LoginNavArgs( loginPasswordPath = LoginPasswordPath( customServerConfig = ServerConfigProvider.EmptyServerConfig ) @@ -1074,6 +955,7 @@ class NewLoginViewModelTest { } fun arrange() = this to NewLoginViewModel( + loginNavArgs, validateEmailOrSSOCodeUseCase, coreLogic, savedStateHandle, @@ -1525,7 +1407,7 @@ class NewLoginViewModelTest { viewModel.handleSSOResult(ssoDeepLinkResult) advanceUntilIdle() - assertEquals(NewLoginAction.Success(NewLoginAction.Success.NextStep.None), expectMostRecentItem()) + assertEquals(NewLoginAction.Success(NewLoginAction.Success.NextStep.None(userId)), expectMostRecentItem()) assertEquals(NewLoginFlowState.Default, viewModel.state.flowState) } } @@ -1546,7 +1428,7 @@ class NewLoginViewModelTest { viewModel.handleSSOResult(ssoDeepLinkResult) advanceUntilIdle() - assertEquals(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync), expectMostRecentItem()) + assertEquals(NewLoginAction.Success(NewLoginAction.Success.NextStep.InitialSync(userId)), expectMostRecentItem()) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt index 0aabcdd0d36..39ddc83d958 100644 --- a/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt @@ -1,16 +1,13 @@ package com.wire.android.ui.registration.details import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import com.wire.android.analytics.RegistrationAnalyticsManagerUseCase import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.datastore.GlobalDataStore import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.UserRegistrationInfo -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.AuthenticationScope @@ -23,7 +20,6 @@ import com.wire.kalium.logic.feature.register.RequestActivationCodeUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -34,7 +30,7 @@ import org.junit.jupiter.api.assertInstanceOf import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class CreateAccountDataDetailViewModelTest { @Test @@ -161,9 +157,6 @@ class CreateAccountDataDetailViewModelTest { } private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var validateEmailUseCase: ValidateEmailUseCase @@ -190,9 +183,6 @@ class CreateAccountDataDetailViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns - CreateAccountDataNavArgs(userRegistrationInfo = UserRegistrationInfo()) - coEvery { coreLogic.versionedAuthenticationScope(any()) } returns autoVersionAuthScopeUseCase coEvery { autoVersionAuthScopeUseCase(null) @@ -218,7 +208,7 @@ class CreateAccountDataDetailViewModelTest { } fun arrange() = this to CreateAccountDataDetailViewModel( - savedStateHandle = savedStateHandle, + createAccountNavArgs = CreateAccountDataNavArgs(userRegistrationInfo = UserRegistrationInfo()), validateEmail = validateEmailUseCase, validatePassword = validatePasswordUseCase, coreLogic = coreLogic, diff --git a/app/src/test/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModelTest.kt index f438127e62f..e557b1cd4be 100644 --- a/app/src/test/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/registration/selector/CreateAccountSelectorViewModelTest.kt @@ -1,13 +1,9 @@ package com.wire.android.ui.registration.selector -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.kalium.logic.configuration.server.ServerConfig import io.mockk.MockKAnnotations -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -16,7 +12,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class CreateAccountSelectorViewModelTest { @Test @@ -46,20 +42,16 @@ class CreateAccountSelectorViewModelTest { @MockK lateinit var globalDataStore: GlobalDataStore - @MockK - lateinit var savedStateHandle: SavedStateHandle + private var navArgs = CreateAccountSelectorNavArgs(ServerConfig.STAGING) init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.navArgs() } returns - CreateAccountSelectorNavArgs(ServerConfig.STAGING) } fun withEmailNavArgs(email: String) = apply { - every { savedStateHandle.navArgs() } returns - CreateAccountSelectorNavArgs(ServerConfig.STAGING, email) + navArgs = CreateAccountSelectorNavArgs(ServerConfig.STAGING, email) } - fun arrange() = this to CreateAccountSelectorViewModel(globalDataStore, savedStateHandle, ServerConfig.STAGING) + fun arrange() = this to CreateAccountSelectorViewModel(globalDataStore, navArgs, ServerConfig.STAGING) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt index 2ca63f65bb0..1edb77c3c97 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt @@ -17,15 +17,12 @@ */ package com.wire.android.ui.settings.devices -import androidx.lifecycle.SavedStateHandle import com.wire.android.assertIs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestClient import com.wire.android.framework.TestUser import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialogState import com.wire.android.ui.authentication.devices.remove.RemoveDeviceError -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.ui.settings.devices.DeviceDetailsViewModelTest.Arrangement.Companion.CLIENT_ID import com.wire.android.ui.settings.devices.DeviceDetailsViewModelTest.Arrangement.Companion.MLS_CLIENT_IDENTITY_WITH_VALID_E2EI import com.wire.kalium.common.error.CoreFailure @@ -57,7 +54,6 @@ import com.wire.kalium.logic.feature.user.ObserveUserInfoUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -71,7 +67,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class DeviceDetailsViewModelTest { @Test @@ -329,9 +324,6 @@ class DeviceDetailsViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var deleteClientUseCase: DeleteClientUseCase @@ -360,10 +352,11 @@ class DeviceDetailsViewModelTest { lateinit var breakSession: BreakSessionUseCase val currentUserId = UserId("currentUserId", "currentUserDomain") + private var navigationArgs = DeviceDetailsViewModelArgs(currentUserId, CLIENT_ID) val viewModel by lazy { DeviceDetailsViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = navigationArgs, deleteClient = deleteClientUseCase, observeClientDetails = observeClientDetails, isPasswordRequired = isPasswordRequiredUseCase, @@ -412,7 +405,7 @@ class DeviceDetailsViewModelTest { } fun withRequiredMockSetup(userId: UserId = currentUserId) = apply { - every { savedStateHandle.navArgs() } returns DeviceDetailsNavArgs( + navigationArgs = DeviceDetailsViewModelArgs( userId = userId, clientId = CLIENT_ID ) diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt new file mode 100644 index 00000000000..1ce054c18a2 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt @@ -0,0 +1,87 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.settings.devices + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DeviceE2EINavigation3EntriesSourceTest { + + @Test + fun givenDeviceContribution_whenInspectingEntries_thenAllSixTypedRoutesAreRegistered() { + val source = sourceFile( + "ui/settings/devices/DeviceE2EINavigation3Entries.kt" + ).readText() + + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + ).forEach { registration -> + assertTrue(source.contains(registration), "Missing $registration") + } + assertTrue(source.contains("resultTypes: List> = emptyList()")) + } + + @Test + fun givenDeviceNavigation3Entries_whenInspectingSource_thenNoLegacyArgumentBridgeIsUsed() { + val entries = sourceFile( + "ui/settings/devices/DeviceE2EINavigation3Entries.kt" + ).readText() + + listOf( + "com.ramcosta.composedestinations", + "ScreenDestination", + "SavedStateHandle", + "Bundle", + "DEFAULT_ARGS_KEY", + ".navArgs()", + ).forEach { forbidden -> + assertFalse(entries.contains(forbidden), "Entries must not reference $forbidden") + } + assertTrue(entries.contains("deviceDetailsViewModel(route.toViewModelArgs())")) + assertTrue(entries.contains("e2eiCertificateDetailsViewModel(route.toViewModelArgs())")) + } + + @Test + fun givenDeviceRouteContracts_whenInspectingImports_thenTheyRemainKmpSourcePure() { + listOf( + "ui/authentication/devices/register/RegisterDeviceRoute.kt", + "ui/authentication/devices/remove/RemoveDeviceRoute.kt", + "ui/e2eiEnrollment/E2EIEnrollmentRoute.kt", + "ui/settings/devices/DeviceManagementNavigation3.kt", + "ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3.kt", + ).forEach { relativePath -> + val source = sourceFile(relativePath).readText() + listOf("android.", "androidx.", "com.ramcosta.", "com.wire.kalium.").forEach { prefix -> + assertFalse( + source.lineSequence() + .filter { it.startsWith("import ") } + .any { it.removePrefix("import ").startsWith(prefix) }, + "$relativePath must not import $prefix", + ) + } + } + } + + private fun sourceFile(relativePath: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File(projectDir, "app/src/main/kotlin/com/wire/android/$relativePath").also { + assertTrue(it.isFile, "Missing source file $relativePath") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3Test.kt new file mode 100644 index 00000000000..25443ac8c3b --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceManagementNavigation3Test.kt @@ -0,0 +1,101 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.settings.devices + +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class DeviceManagementNavigation3Test { + + @Test + fun givenDeviceDetailsRoute_whenSerializedAndRestored_thenScopeTargetAndIdentityArePreserved() { + val route = DeviceDetailsRoute( + sessionId = WireSessionId("current", "wire.example"), + targetUserId = DeviceTargetUserId("other", "remote.example"), + clientId = "client-42", + entryId = WireNavEntryId("device-entry"), + ) + + val restored = Json.decodeFromString( + Json.encodeToString(route) + ) + + assertEquals(route, restored) + assertEquals("current", restored.sessionId.value) + assertEquals("other", restored.targetUserId.value) + assertEquals("device-entry", restored.entryId.value) + assertEquals("app/device_details_screen", restored.routeId) + } + + @Test + fun givenLegacyDeviceArgs_whenMappingToNavigationAndBack_thenTargetUserAndClientAreUnchanged() { + val legacy = DeviceDetailsNavArgs( + userId = UserId("other", "remote.example"), + clientId = ClientId("client-42"), + ) + + val route = legacy.toDeviceDetailsRoute( + sessionId = WireSessionId("current", "wire.example"), + entryId = WireNavEntryId("device-entry"), + ) + + assertEquals(legacy, route.toLegacyNavArgs()) + assertEquals("current", route.sessionId.value) + assertEquals("other", route.targetUserId.value) + assertEquals( + DeviceDetailsViewModelArgs( + userId = UserId("other", "remote.example"), + clientId = ClientId("client-42"), + ), + route.toViewModelArgs(), + ) + } + + @Test + fun givenSameArguments_whenCreatingTwoSelfDeviceEntries_thenEntryIdentityIsDifferent() { + val first = SelfDevicesRoute(WireSessionId("user", "wire.example")) + val second = SelfDevicesRoute(WireSessionId("user", "wire.example")) + + assertNotEquals(first.entryId, second.entryId) + assertEquals("app/self_devices_screen", first.routeId) + assertEquals(first.routeId, second.routeId) + } + + @Test + fun givenBlankTargetOrClientIdentity_whenCreatingDeviceRoute_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + DeviceTargetUserId("", "wire.example") + } + assertThrows(IllegalArgumentException::class.java) { + DeviceDetailsRoute( + sessionId = WireSessionId("current", "wire.example"), + targetUserId = DeviceTargetUserId("other", "remote.example"), + clientId = "", + ) + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3Test.kt new file mode 100644 index 00000000000..822be3637c5 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsNavigation3Test.kt @@ -0,0 +1,94 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.settings.devices.e2ei + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class E2eiCertificateDetailsNavigation3Test { + + @Test + fun givenDuringLoginLegacyDetails_whenMapping_thenCertificateAndSessionArePreserved() { + val legacy = E2eiCertificateDetailsScreenNavArgs( + E2EICertificateDetails.DuringLoginCertificateDetails("certificate-data") + ) + + val route = legacy.toE2eiCertificateDetailsRoute( + sessionId = WireSessionId("user", "wire.example"), + entryId = WireNavEntryId("certificate-entry"), + ) + + assertEquals( + E2eiCertificateDetailsPayload.DuringLogin("certificate-data"), + route.details, + ) + assertEquals("app/e2ei_certificate_details_screen", route.routeId) + assertEquals("user", route.sessionId.value) + assertEquals( + E2eiCertificateDetailsViewModelArgs.DuringLogin("certificate-data"), + route.toViewModelArgs(), + ) + } + + @Test + fun givenAfterLoginRoute_whenSerializedAndRestored_thenKmpSafePayloadAndIdentityArePreserved() { + val route = E2eiCertificateDetailsRoute( + sessionId = WireSessionId("user", "wire.example"), + details = E2eiCertificateDetailsPayload.AfterLogin( + certificate = "certificate-data", + userHandle = "alice", + ), + entryId = WireNavEntryId("certificate-entry"), + ) + + val restored = Json.decodeFromString( + Json.encodeToString(route) + ) + + assertEquals(route, restored) + assertEquals("certificate-entry", restored.entryId.value) + assertEquals( + E2eiCertificateDetailsViewModelArgs.AfterLogin( + certificate = "certificate-data", + userHandle = "alice", + ), + restored.toViewModelArgs(), + ) + } + + @Test + fun givenSameArguments_whenCreatingTwoCertificateEntries_thenEntryIdentityIsDifferent() { + val first = E2eiCertificateDetailsRoute( + WireSessionId("user", "wire.example"), + E2eiCertificateDetailsPayload.DuringLogin("certificate"), + ) + val second = E2eiCertificateDetailsRoute( + WireSessionId("user", "wire.example"), + E2eiCertificateDetailsPayload.DuringLogin("certificate"), + ) + + assertNotEquals(first.entryId, second.entryId) + assertEquals(first.routeId, second.routeId) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3SourcePurityTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3SourcePurityTest.kt new file mode 100644 index 00000000000..b74835ac3a6 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3SourcePurityTest.kt @@ -0,0 +1,118 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class UserProfileNavigation3SourcePurityTest { + + @Test + fun givenUserProfileRouteContracts_whenInspectingImports_thenTheyAreKmpSourcePure() { + contractFiles.forEach { relativePath -> + val source = sourceFile(relativePath).readText() + val imports = source + .lineSequence() + .filter { it.startsWith("import ") } + .map { it.removePrefix("import ").substringBefore(" as ") } + .toList() + forbiddenImportPrefixes.forEach { forbidden -> + assertFalse( + imports.any { it.startsWith(forbidden) }, + "$relativePath must not import $forbidden", + ) + } + val sourceWithoutComments = source + .replace(Regex("/\\*[\\s\\S]*?\\*/"), "") + .replace(Regex("//.*"), "") + forbiddenContractTypes.forEach { forbidden -> + assertFalse( + Regex("\\b${Regex.escape(forbidden)}\\b").containsMatchIn(sourceWithoutComments), + "$relativePath must not reference $forbidden", + ) + } + } + } + + @Test + fun givenUserProfileContribution_whenInspectingEntries_thenAllFiveRoutesAreRegistered() { + val source = sourceFile("UserProfileNavigation3Entries.kt").readText() + + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + ).forEach { registration -> + assertTrue(source.contains(registration), "Missing $registration") + } + assertTrue(source.contains("AvatarPickerNavigation3ResultType")) + assertTrue(source.contains("ConnectionRequestIgnoredNavigation3ResultType")) + assertFalse(source.contains("ScreenDestination")) + assertFalse(source.contains("com.ramcosta.composedestinations")) + } + + @Test + fun givenArgumentBackedViewModels_whenInspectingSources_thenTheyUseTypedAssistedArguments() { + listOf( + "qr/SelfQRCodeViewModel.kt" to "SelfQrCodeViewModelArgs", + "other/OtherUserProfileScreenViewModel.kt" to "OtherUserProfileViewModelArgs", + "service/ServiceDetailsViewModel.kt" to "ServiceDetailsViewModelArgs", + ).forEach { (relativePath, argumentType) -> + val source = sourceFile(relativePath).readText() + assertTrue(source.contains("navigationArgs: $argumentType")) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("generated.app.navArgs")) + } + + val entries = sourceFile("UserProfileNavigation3Entries.kt").readText() + assertTrue(entries.contains("toSelfQrCodeViewModelArgs()")) + assertTrue(entries.contains("toOtherUserProfileViewModelArgs()")) + assertTrue(entries.contains("toServiceDetailsViewModelArgs()")) + } + + private fun sourceFile(relativePath: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + projectDir, + "app/src/main/kotlin/com/wire/android/ui/userprofile/$relativePath", + ).also { + assertTrue(it.isFile, "Missing source file $relativePath") + } + } + + private companion object { + val contractFiles = listOf( + "UserProfileQualifiedId.kt", + "avatarpicker/AvatarPickerNavigation3.kt", + "qr/SelfQrCodeNavigation3.kt", + "self/SelfUserProfileRoute.kt", + "other/OtherUserProfileNavigation3.kt", + "service/ServiceDetailsNavigation3.kt", + ) + val forbiddenImportPrefixes = listOf( + "android.", + "androidx.", + "com.ramcosta.", + "com.wire.kalium.", + ) + val forbiddenContractTypes = listOf( + "Parcelable", + "Parcelize", + "NavArgs", + "Destination", + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Test.kt new file mode 100644 index 00000000000..20d7961c2dd --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/UserProfileNavigation3Test.kt @@ -0,0 +1,36 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile + +import com.wire.kalium.logic.data.id.QualifiedID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class UserProfileNavigation3Test { + + @Test + fun givenLegacyQualifiedId_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + val legacy = QualifiedID("user", "wire.example") + + assertEquals(legacy, legacy.toUserProfileQualifiedId().toQualifiedId()) + } + + @Test + fun givenBlankQualifiedIdPart_whenCreatingNavigationId_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + UserProfileQualifiedId("", "wire.example") + } + assertThrows(IllegalArgumentException::class.java) { + UserProfileQualifiedId("user", "") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3Test.kt new file mode 100644 index 00000000000..36eddea82d3 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerNavigation3Test.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.avatarpicker + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class AvatarPickerNavigation3Test { + + @Test + fun givenAvatarPickerRoute_whenSerializedAndRestored_thenScopeAndIdentityArePreserved() { + val route = AvatarPickerRoute( + sessionId = WireSessionId("user", "wire.example"), + entryId = WireNavEntryId("avatar-entry"), + ) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals("app/avatar_picker_screen", restored.routeId) + } + + @Test + fun givenSameArguments_whenCreatingTwoAvatarEntries_thenIdentityIsDifferent() { + val sessionId = WireSessionId("user", "wire.example") + + assertNotEquals( + AvatarPickerRoute(sessionId).entryId, + AvatarPickerRoute(sessionId).entryId, + ) + } + + @Test + fun givenLegacyNullableResult_whenMappingToNavigationAndBack_thenNullAndValueArePreserved() { + assertNull(null.toAvatarPickerResult().toLegacyResult()) + assertEquals( + "asset-id", + "asset-id".toAvatarPickerResult().toLegacyResult(), + ) + assertEquals("user-profile.avatar-picker", AvatarPickerResultContract.id.value) + } + + @Test + fun givenSuccessfulNullAvatarResult_whenComparedWithCancellation_thenTheyRemainDistinct() { + val successfulNull = com.wire.navigation.WireNavResult.Value( + AvatarPickerResult(assetId = null) + ) + + assertNotEquals(successfulNull, com.wire.navigation.WireNavResult.Canceled) + assertNull(successfulNull.value.assetId) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3Test.kt new file mode 100644 index 00000000000..02d121181db --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileNavigation3Test.kt @@ -0,0 +1,91 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.other + +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class OtherUserProfileNavigation3Test { + + @Test + fun givenLegacyOtherProfileArgs_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + val legacy = OtherUserProfileNavArgs( + userId = UserId("other", "remote.example"), + groupConversationId = ConversationId("group", "wire.example"), + ) + + val route = legacy.toOtherUserProfileRoute( + sessionId = WireSessionId("self", "wire.example"), + entryId = WireNavEntryId("other-profile-entry"), + ) + + assertEquals(legacy, route.toLegacyNavArgs()) + assertEquals("self", route.sessionId.value) + assertEquals("other", route.targetUserId.value) + assertEquals("app/other_user_profile_screen", route.routeId) + } + + @Test + fun givenOtherProfileRoute_whenSerializedAndRestored_thenArgumentsAndIdentityArePreserved() { + val route = OtherUserProfileNavArgs( + userId = UserId("other", "remote.example"), + ).toOtherUserProfileRoute( + sessionId = WireSessionId("self", "wire.example"), + entryId = WireNavEntryId("other-profile-entry"), + ) + + assertEquals( + route, + Json.decodeFromString(Json.encodeToString(route)), + ) + assertEquals( + OtherUserProfileViewModelArgs( + targetUserId = route.targetUserId, + groupConversationId = null, + ), + route.toViewModelArgs(), + ) + } + + @Test + fun givenLegacyIgnoredRequestResult_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + val result = "other-user".toConnectionRequestIgnoredResult() + + assertEquals("other-user", result.toLegacyResult()) + assertEquals("other-user", result.userName) + assertEquals( + "user-profile.connection-request-ignored", + ConnectionRequestIgnoredResultContract.id.value, + ) + } + + @Test + fun givenIgnoredRequestUserName_whenSerialized_thenPersistedResultRemainsCompatible() { + val result = ConnectionRequestIgnoredResult("Alice") + + assertEquals("""{"userId":"Alice"}""", Json.encodeToString(result)) + assertEquals(result, Json.decodeFromString("""{"userId":"Alice"}""")) + } + + @Test + fun givenBlankIgnoredRequestUserName_whenCreatingResult_thenItIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + ConnectionRequestIgnoredResult("") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt index 88fa67d13d5..bdf49b2693f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt @@ -21,7 +21,6 @@ package com.wire.android.ui.userprofile.other import app.cash.turbine.test import com.wire.android.assertIs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.ui.home.conversations.details.participants.usecase.ConversationRoleData import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.Conversation.Member @@ -50,7 +49,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class OtherUserProfileScreenViewModelTest { @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt index 900703f9ed3..809cc7029a8 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt @@ -18,14 +18,13 @@ package com.wire.android.ui.userprofile.other -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveConversationRoleForUserUseCase import com.wire.android.ui.home.conversationslist.model.Membership -import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.ui.userprofile.toUserProfileQualifiedId import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModelTest.Companion.CONVERSATION_ID import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModelTest.Companion.USER_ID import com.wire.kalium.logic.data.id.ConversationId @@ -52,9 +51,6 @@ import kotlinx.coroutines.flow.flowOf internal class OtherUserProfileViewModelArrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getOneToOneConversation: GetOneToOneConversationDetailsUseCase @@ -97,6 +93,11 @@ internal class OtherUserProfileViewModelArrangement { @MockK lateinit var isE2EIEnabled: IsE2EIEnabledUseCase + private var navigationArgs = OtherUserProfileViewModelArgs( + targetUserId = USER_ID.toUserProfileQualifiedId(), + groupConversationId = CONVERSATION_ID.toUserProfileQualifiedId(), + ) + private val viewModel by lazy { OtherUserProfileScreenViewModel( TestDispatcherProvider(), @@ -111,7 +112,7 @@ internal class OtherUserProfileViewModelArrangement { isOneToOneConversationCreated, mlsClientIdentity, isE2EIEnabled, - savedStateHandle, + navigationArgs, ) } @@ -119,11 +120,6 @@ internal class OtherUserProfileViewModelArrangement { MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns OtherUserProfileNavArgs( - groupConversationId = CONVERSATION_ID, - userId = USER_ID - ) - coEvery { observeConversationRoleForUserUseCase.invoke(any(), any()) } returns flowOf(OtherUserProfileScreenViewModelTest.CONVERSATION_ROLE_DATA) @@ -149,9 +145,9 @@ internal class OtherUserProfileViewModelArrangement { } fun withConversationIdInSavedState(conversationId: ConversationId?) = apply { - every { savedStateHandle.navArgs() } returns OtherUserProfileNavArgs( - userId = USER_ID, - groupConversationId = conversationId + navigationArgs = OtherUserProfileViewModelArgs( + targetUserId = USER_ID.toUserProfileQualifiedId(), + groupConversationId = conversationId?.toUserProfileQualifiedId(), ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModelTest.kt index 48d16e47e03..f4f58757c83 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeViewModelTest.kt @@ -1,20 +1,16 @@ package com.wire.android.ui.userprofile.qr import android.content.Context -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.framework.TestUser -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.util.newServerConfig import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -25,7 +21,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class SelfQRCodeViewModelTest { @Test fun `given user is on self qr code screen, then data is loaded correctly`() = runTest { @@ -45,9 +40,6 @@ class SelfQRCodeViewModelTest { } private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var selfServerConfig: SelfServerConfigUseCase @@ -61,11 +53,10 @@ class SelfQRCodeViewModelTest { coEvery { selfServerConfig.invoke() } returns SelfServerConfigUseCase.Result.Success( serverLinks = newServerConfig(1).copy(links = ServerConfig.STAGING) ) - every { savedStateHandle.navArgs() } returns SelfQrCodeNavArgs("handle", false) } fun arrange() = this to SelfQRCodeViewModel( - savedStateHandle = savedStateHandle, + navigationArgs = SelfQrCodeViewModelArgs("handle", false), context = context, selfUserId = TestUser.SELF_USER.id, selfServerLinks = selfServerConfig, diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3Test.kt new file mode 100644 index 00000000000..157d8a4f8b5 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/qr/SelfQrCodeNavigation3Test.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.qr + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SelfQrCodeNavigation3Test { + + @Test + fun givenLegacyQrArgs_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + val legacy = SelfQrCodeNavArgs( + userHandle = "alice", + isTeamMember = true, + ) + + val route = legacy.toSelfQrCodeRoute( + sessionId = WireSessionId("user", "wire.example"), + entryId = WireNavEntryId("qr-entry"), + ) + + assertEquals(legacy, route.toLegacyNavArgs()) + assertEquals("app/self_q_r_code_screen", route.routeId) + } + + @Test + fun givenQrRoute_whenSerializedAndRestored_thenArgumentsAndIdentityArePreserved() { + val route = SelfQrCodeRoute( + sessionId = WireSessionId("user", "wire.example"), + userHandle = "alice", + isTeamMember = false, + entryId = WireNavEntryId("qr-entry"), + ) + + assertEquals( + route, + Json.decodeFromString(Json.encodeToString(route)), + ) + assertEquals( + SelfQrCodeViewModelArgs("alice", false), + route.toViewModelArgs(), + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileNavigation3Test.kt new file mode 100644 index 00000000000..9b1cd5ba534 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileNavigation3Test.kt @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.self + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class SelfUserProfileNavigation3Test { + + @Test + fun givenSelfProfileRoute_whenSerializedAndRestored_thenScopeAndIdentityArePreserved() { + val route = SelfUserProfileRoute( + sessionId = WireSessionId("user", "wire.example"), + entryId = WireNavEntryId("self-profile-entry"), + ) + + assertEquals( + route, + Json.decodeFromString(Json.encodeToString(route)), + ) + assertEquals("app/self_user_profile_screen", route.routeId) + } + + @Test + fun givenSameSession_whenCreatingTwoSelfProfileEntries_thenIdentityIsDifferent() { + val sessionId = WireSessionId("user", "wire.example") + + assertNotEquals( + SelfUserProfileRoute(sessionId).entryId, + SelfUserProfileRoute(sessionId).entryId, + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelTest.kt index 8cb82aa6e5e..7f5b2557b22 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelTest.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.userprofile.self import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import io.mockk.coEvery import io.mockk.coVerify import com.wire.android.ui.legalhold.banner.LegalHoldUIState @@ -32,7 +31,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class SelfUserProfileViewModelTest { @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3Test.kt new file mode 100644 index 00000000000..c2cb83576a0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsNavigation3Test.kt @@ -0,0 +1,82 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.service + +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.BotService +import com.wire.kalium.logic.data.user.UserId +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ServiceDetailsNavigation3Test { + + @Test + fun givenLegacyAppArgs_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + assertLegacyRoundTrip( + ServiceDetailsNavArgs( + conversationId = ConversationId("conversation", "wire.example"), + id = ServiceDetailsNavArgs.Id.AppId(UserId("app", "provider.example")), + ) + ) + } + + @Test + fun givenLegacyBotArgs_whenMappingToNavigationAndBack_thenValueIsUnchanged() { + assertLegacyRoundTrip( + ServiceDetailsNavArgs( + conversationId = null, + id = ServiceDetailsNavArgs.Id.BotServiceId( + BotService("bot", "provider.example") + ), + ) + ) + } + + @Test + fun givenServiceRoute_whenSerializedAndRestored_thenTargetAndIdentityArePreserved() { + val route = ServiceDetailsRoute( + sessionId = WireSessionId("user", "wire.example"), + conversationId = null, + target = ServiceProfileTarget.Bot( + com.wire.android.ui.userprofile.UserProfileQualifiedId( + "bot", + "provider.example", + ) + ), + entryId = WireNavEntryId("service-entry"), + ) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals("app/service_details_screen", restored.routeId) + assertEquals( + ServiceDetailsViewModelArgs( + conversationId = route.conversationId, + target = route.target, + ), + route.toViewModelArgs(), + ) + } + + private fun assertLegacyRoundTrip(legacy: ServiceDetailsNavArgs) { + val route = legacy.toServiceDetailsRoute( + sessionId = WireSessionId("user", "wire.example"), + entryId = WireNavEntryId("service-entry"), + ) + + assertEquals(legacy, route.toLegacyNavArgs()) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt index 43db9d38676..0d77917cf35 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/service/ServiceDetailsViewModelTest.kt @@ -17,17 +17,14 @@ */ package com.wire.android.ui.userprofile.service -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.TestUser import com.wire.android.model.asSnackBarMessage import com.wire.android.ui.home.conversations.details.participants.usecase.ConversationRoleData import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveConversationRoleForUserUseCase -import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.framework.TestConversation import com.wire.android.framework.TestConversationDetails import com.wire.kalium.common.error.CoreFailure @@ -59,7 +56,6 @@ import com.wire.kalium.logic.feature.service.ObserveIsServiceMemberUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -70,7 +66,6 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) class ServiceDetailsViewModelTest { @Test @@ -757,10 +752,11 @@ class ServiceDetailsViewModelTest { @MockK lateinit var getOrCreateOneToOneConversation: GetOrCreateOneToOneConversationUseCase - @MockK - lateinit var savedStateHandle: SavedStateHandle - private val selfUser = TestUser.SELF_USER + private var navigationArgs = ServiceDetailsNavArgs( + conversationId = CONVERSATION_ID, + id = ServiceDetailsNavArgs.Id.BotServiceId(BotService("service", "provider")), + ).toViewModelArgs() private val viewModel by lazy { ServiceDetailsViewModelImpl( @@ -778,7 +774,7 @@ class ServiceDetailsViewModelTest { addMemberToConversation, isOneToOneConversationCreated, getOrCreateOneToOneConversation, - savedStateHandle + navigationArgs ) } @@ -800,17 +796,17 @@ class ServiceDetailsViewModelTest { } fun withServiceBot(service: BotService, conversationId: ConversationId? = CONVERSATION_ID) = apply { - every { savedStateHandle.navArgs() } returns ServiceDetailsNavArgs( + navigationArgs = ServiceDetailsNavArgs( conversationId, ServiceDetailsNavArgs.Id.BotServiceId(service) - ) + ).toViewModelArgs() } fun withServiceApp(service: UserId, conversationId: ConversationId? = CONVERSATION_ID) = apply { - every { savedStateHandle.navArgs() } returns ServiceDetailsNavArgs( + navigationArgs = ServiceDetailsNavArgs( conversationId, ServiceDetailsNavArgs.Id.AppId(service) - ) + ).toViewModelArgs() } fun withAppsAllowedForUsage(result: AppsAllowedResult) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3SourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3SourceTest.kt new file mode 100644 index 00000000000..32bb194fa91 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3SourceTest.kt @@ -0,0 +1,98 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.teammigration + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TeamMigrationNavigation3SourceTest { + + @Test + fun givenTeamMigrationContract_whenInspectingImports_thenItIsKmpSourcePure() { + val source = sourceFile("TeamMigrationNavigation3.kt").readText() + val imports = source + .lineSequence() + .filter { it.startsWith("import ") } + .map { it.removePrefix("import ") } + .toList() + + listOf("android.", "androidx.", "com.ramcosta.", "com.wire.kalium.").forEach { forbidden -> + assertFalse(imports.any { it.startsWith(forbidden) }, "Contract imports $forbidden") + } + listOf("Bundle", "SavedStateHandle", "DEFAULT_ARGS_KEY", "NavArgs").forEach { forbidden -> + assertFalse(source.contains(forbidden), "Contract references $forbidden") + } + } + + @Test + fun givenTeamMigrationContribution_whenInspectingEntries_thenAllRoutesAndFlowOwnerAreUsed() { + val source = sourceFile("TeamMigrationNavigation3Entries.kt").readText() + + listOf( + "wireEntry", + "wireEntry", + "wireEntry", + "wireEntry", + "wireViewModelStoreOwner(WireViewModelOwner.Flow(flowId))", + "teamMigrationViewModel(viewModelStoreOwner = flowOwner)", + "WireBackStackMode.REMOVE_CURRENT_NESTED_FLOW", + ).forEach { expected -> + assertTrue(source.contains(expected), "Missing $expected") + } + assertFalse(source.contains("ScreenDestination")) + assertFalse(source.contains("com.ramcosta.composedestinations")) + assertFalse(source.contains("Bundle")) + assertFalse(source.contains("DEFAULT_ARGS_KEY")) + } + + @Test + fun givenLegacyScreens_whenInspectingAdapters_thenBothRuntimesShareNeutralContent() { + listOf( + Triple( + "step1/TeamMigrationTeamPlanStepScreen.kt", + "TeamMigrationTeamPlanRouteScreen", + "TeamMigrationTeamPlanStepScreenContent(", + ), + Triple( + "step2/TeamMigrationTeamNameStepScreen.kt", + "TeamMigrationTeamNameRouteScreen", + "TeamMigrationTeamNameStepScreenContent(", + ), + Triple( + "step3/TeamMigrationConfirmationStepScreen.kt", + "TeamMigrationConfirmationRouteScreen", + "TeamMigrationConfirmationStepScreenContent(", + ), + Triple( + "step4/TeamMigrationDoneStepScreen.kt", + "TeamMigrationDoneRouteScreen", + "TeamMigrationDoneStepContent(", + ), + ).forEach { (path, adapter, content) -> + val source = sourceFile(path).readText() + assertTrue(source.contains("internal fun $adapter")) + assertTrue(source.substringAfter("internal fun $adapter").contains(content)) + } + } + + private fun sourceFile(relativePath: String): File { + val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + .first { File(it, "app/src/main/kotlin").isDirectory } + return File( + projectDir, + "app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/$relativePath", + ).also { + assertTrue(it.isFile, "Missing source file $relativePath") + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Test.kt new file mode 100644 index 00000000000..8e8e19f95d5 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationNavigation3Test.kt @@ -0,0 +1,117 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.userprofile.teammigration + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class TeamMigrationNavigation3Test { + + private val sessionId = WireSessionId("user", "wire.example") + private val flowId = "team-migration:flow-42" + + @Test + fun givenTeamMigrationRoutes_whenSerializedAndRestored_thenScopeAndIdentityArePreserved() { + assertSerializationRoundTrip( + TeamMigrationTeamPlanRoute( + sessionId, + flowId, + isMigrationDotActive = true, + entryId = WireNavEntryId("plan"), + ) + ) + assertSerializationRoundTrip( + TeamMigrationTeamNameRoute(sessionId, flowId, WireNavEntryId("name")) + ) + assertSerializationRoundTrip( + TeamMigrationConfirmationRoute(sessionId, flowId, WireNavEntryId("confirmation")) + ) + assertSerializationRoundTrip( + TeamMigrationDoneRoute(sessionId, flowId, WireNavEntryId("done")) + ) + } + + @Test + fun givenTeamMigrationRoutes_whenReadingRouteIds_thenStableRouteIdsArePreserved() { + val expectations = mapOf( + TeamMigrationTeamPlanRoute(sessionId, flowId) to + TeamMigrationTeamPlanRoute.ROUTE_ID, + TeamMigrationTeamNameRoute(sessionId, flowId) to + TeamMigrationTeamNameRoute.ROUTE_ID, + TeamMigrationConfirmationRoute(sessionId, flowId) to + TeamMigrationConfirmationRoute.ROUTE_ID, + TeamMigrationDoneRoute(sessionId, flowId) to + TeamMigrationDoneRoute.ROUTE_ID, + ) + + expectations.forEach { (route, expectedRouteId) -> + assertEquals(expectedRouteId, route.routeId) + } + } + + @Test + fun givenStartFactory_whenCreatingTwoFlows_thenOwnershipAndEntryIdentityAreUnique() { + val first = TeamMigrationTeamPlanRoute.start(sessionId, isMigrationDotActive = true) + val second = TeamMigrationTeamPlanRoute.start(sessionId, isMigrationDotActive = true) + + assertNotEquals(first.entryId, second.entryId) + assertNotEquals(first.flowId, second.flowId) + assertEquals("team-migration:${first.entryId.value}", first.flowId) + assertEquals(true, first.isMigrationDotActive) + } + + @Test + fun givenEveryStepOfOneFlow_whenReadingScope_thenSessionAndFlowAreIdentical() { + val routes = allRoutes() + + assertEquals(setOf(sessionId), routes.map { it.sessionId }.toSet()) + assertEquals(setOf(flowId), routes.map { it.flowId }.toSet()) + } + + @Test + fun givenBlankFlowId_whenCreatingAnyTeamMigrationRoute_thenItIsRejected() { + val factories: List<() -> TeamMigrationRoute> = listOf( + { TeamMigrationTeamPlanRoute(sessionId, " ") }, + { TeamMigrationTeamNameRoute(sessionId, " ") }, + { TeamMigrationConfirmationRoute(sessionId, " ") }, + { TeamMigrationDoneRoute(sessionId, " ") }, + ) + + factories.forEach { factory -> + assertThrows(IllegalArgumentException::class.java) { + factory() + } + } + } + + private fun allRoutes(): List = listOf( + TeamMigrationTeamPlanRoute(sessionId, flowId), + TeamMigrationTeamNameRoute(sessionId, flowId), + TeamMigrationConfirmationRoute(sessionId, flowId), + TeamMigrationDoneRoute(sessionId, flowId), + ) + + private inline fun assertSerializationRoundTrip(route: T) { + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(route, restored) + assertEquals(sessionId, restored.sessionId) + assertEquals(flowId, restored.flowId) + assertEquals(route.entryId, restored.entryId) + assertEquals(route.routeId, restored.routeId) + } +} diff --git a/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt b/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt index 7ce9ed936e9..52db7ce5220 100644 --- a/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/DeepLinkProcessorTest.kt @@ -207,7 +207,7 @@ class DeepLinkProcessorTest { val conversationResult = deepLinkProcessor(arrangement.uri) assertInstanceOf(DeepLinkResult.OpenConversation::class.java, conversationResult) assertEquals( - DeepLinkResult.OpenConversation(CONVERSATION_ID, true), + DeepLinkResult.OpenConversation(CONVERSATION_ID, true, OTHER_USER_ID), conversationResult ) } @@ -236,7 +236,7 @@ class DeepLinkProcessorTest { val conversationResult = deepLinkProcessor(arrangement.uri) assertInstanceOf(DeepLinkResult.OpenOtherUserProfile::class.java, conversationResult) assertEquals( - DeepLinkResult.OpenOtherUserProfile(OTHER_USER_ID, true), + DeepLinkResult.OpenOtherUserProfile(OTHER_USER_ID, true, OTHER_USER_ID), conversationResult ) } diff --git a/build-logic/plugins/build.gradle.kts b/build-logic/plugins/build.gradle.kts index 1a4b06ede40..acb604aba13 100644 --- a/build-logic/plugins/build.gradle.kts +++ b/build-logic/plugins/build.gradle.kts @@ -69,10 +69,6 @@ gradlePlugin { id = libs.plugins.wire.android.test.library.get().pluginId implementationClass = "AndroidTestLibraryConventionPlugin" } - register("androidNavigationConventionPlugin") { - id = libs.plugins.wire.android.navigation.get().pluginId - implementationClass = "AndroidNavigationConventionPlugin" - } register("composeCompilerConventionPlugin") { id = libs.plugins.wire.compose.compiler.get().pluginId implementationClass = "ComposeCompilerConventionPlugin" diff --git a/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt b/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt index 44de4652a63..9a7024adb06 100644 --- a/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt +++ b/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt @@ -20,7 +20,7 @@ import java.io.File object AndroidSdk { const val min = 26 - const val compile = 36 + const val compile = 37 const val target = compile } diff --git a/build-logic/plugins/src/main/kotlin/AndroidNavigationConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/AndroidNavigationConventionPlugin.kt deleted file mode 100644 index 6a860805fc3..00000000000 --- a/build-logic/plugins/src/main/kotlin/AndroidNavigationConventionPlugin.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.kotlin.dsl.dependencies -class AndroidNavigationConventionPlugin : Plugin { - override fun apply(target: Project): Unit = with(target) { - with(pluginManager) { - apply("com.google.devtools.ksp") - } - - dependencies { - add("implementation", project(":core:navigation")) - add("implementation", findLibrary("compose.navigation")) - add("implementation", findLibrary("compose.destinations.core")) - add("ksp", findLibrary("compose.destinations.ksp")) - } - } -} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index fea062cdef9..789da63fb64 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -29,7 +29,7 @@ repositories { } dependencies { - implementation("com.android.tools.build:gradle:${klibs.versions.agp.get()}") + implementation(libs.android.gradlePlugin) implementation(libs.kotlin.gradlePlugin) implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:${klibs.versions.detekt.get()}") implementation("org.jetbrains.kotlinx:kover-gradle-plugin:${libs.versions.kover.get()}") diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts index 299669f09c0..e99140b77c5 100644 --- a/core/di/build.gradle.kts +++ b/core/di/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id(libs.plugins.wire.android.library.get().pluginId) id(libs.plugins.wire.kover.get().pluginId) id(libs.plugins.wire.compose.compiler.get().pluginId) + id(BuildPlugins.junit5) alias(libs.plugins.compose.stability.analyzer) } @@ -13,4 +14,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.resaca.core) implementation(libs.resaca.metro) + + testImplementation(libs.junit5.core) + testRuntimeOnly(libs.junit5.engine) } diff --git a/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt b/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt index 74d0ee7e152..4aaeac9b177 100644 --- a/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt +++ b/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt @@ -21,14 +21,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.sebaslogen.resaca.KeyInScopeResolver -import com.wire.android.di.metro.LocalWireViewModelScopeKey import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import kotlin.reflect.KClass @@ -42,13 +39,13 @@ inline fun w arguments: R, previewProvider: PreviewProvider = EmptyPreviewProvider, clearDelay: Duration? = null, - noinline create: FactoryType.(SavedStateHandle, R) -> T, + noinline create: FactoryType.(R) -> T, ): S where T : ViewModel, T : S, FactoryType : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { resacaMetroViewModelScoped( - key = scopedResacaKey(LocalWireViewModelScopeKey.current, arguments.key?.toString()), + key = arguments.key, clearDelay = clearDelay, - factory = manualScopedViewModelFactory { extras -> - create(extras.createSavedStateHandle(), arguments) + factory = manualScopedViewModelFactory { + create(arguments) }, creationExtras = defaultViewModelCreationExtras(), ) as S @@ -61,51 +58,20 @@ inline fun w noinline keyInScopeResolver: KeyInScopeResolver, previewProvider: PreviewProvider = EmptyPreviewProvider, clearDelay: Duration? = null, - noinline create: FactoryType.(SavedStateHandle, R) -> T, + noinline create: FactoryType.(R) -> T, ): S where T : ViewModel, T : S, FactoryType : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { - val argumentsKey = requireNotNull(arguments.key?.toString()) { - "Scoped key must not be null for ${T::class.qualifiedName}" - } + val argumentsKey = arguments.key resacaMetroViewModelScoped( - key = requireNotNull(scopedResacaKey(LocalWireViewModelScopeKey.current, argumentsKey)), + key = argumentsKey, keyInScopeResolver = { keyInScopeResolver(argumentsKey) }, clearDelay = clearDelay, - factory = manualScopedViewModelFactory { extras -> - create(extras.createSavedStateHandle(), arguments) - }, - creationExtras = defaultViewModelCreationExtras(), - ) as S -} - -@Composable -@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") -inline fun wireManualMetroViewModelScoped( - previewProvider: PreviewProvider = EmptyPreviewProvider, - clearDelay: Duration? = null, - noinline create: FactoryType.(SavedStateHandle) -> T, -): S where T : ViewModel, T : S, FactoryType : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { - resacaMetroViewModelScoped( - key = scopedResacaKey(LocalWireViewModelScopeKey.current), - clearDelay = clearDelay, - factory = manualScopedViewModelFactory { extras -> - create(extras.createSavedStateHandle()) + factory = manualScopedViewModelFactory { + create(arguments) }, creationExtras = defaultViewModelCreationExtras(), ) as S } -@Composable -inline fun wireManualMetroViewModelScoped( - previewProvider: PreviewProvider = EmptyPreviewProvider, - clearDelay: Duration? = null, - noinline create: FactoryType.(SavedStateHandle) -> T, -): T where T : ViewModel, FactoryType : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { - wireManualMetroViewModelScoped( - clearDelay = clearDelay, - create = create, - ) -} - @Composable @PublishedApi internal inline fun manualScopedViewModelFactory( @@ -133,13 +99,6 @@ internal fun defaultViewModelCreationExtras(): CreationExtras { } } -@PublishedApi -internal fun scopedResacaKey(scopeKey: String?, key: String? = null): String? = - when (scopeKey) { - null -> key - else -> listOfNotNull(key, scopeKey).joinToString(":") - } - @Composable @PublishedApi internal inline fun PreviewProvider.findPreviewOr(provideViewModel: @Composable () -> S): S { @@ -165,7 +124,7 @@ val espresso * It is used to provide a unique key for the scoped ViewModel. */ interface ScopedArgs { - val key: Any? + val key: String } @Stable diff --git a/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt b/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt index 2caf1227340..c7737803031 100644 --- a/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt +++ b/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt @@ -17,143 +17,10 @@ */ package com.wire.android.di.metro -import androidx.compose.runtime.Composable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.wire.android.di.EmptyPreviewProvider -import com.wire.android.di.PreviewProvider -import com.wire.android.di.findPreviewOr -import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory -import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel -import dev.zacsweers.metrox.viewmodel.metroViewModel - interface MetroViewModelGraph { + /** + * Metro graph diagnostic identity. It must never participate in ViewModel instance keys. + */ val viewModelScopeKey: String? get() = null } - -val LocalWireViewModelScopeKey = staticCompositionLocalOf { - null -} - -fun sessionKeyedMetroViewModelKey(defaultKey: String?, key: String?, scopeKey: String?): String? { - if (scopeKey == null) return key - return "${key ?: defaultKey}:$scopeKey" -} - -/** - * Creates a Metro-backed ViewModel using the current Wire ViewModel scope key. - * - * Session screens provide [LocalWireViewModelScopeKey] from the active account/session graph. - * Including that value in the ViewModel key prevents Compose from reusing a ViewModel that was - * created for a different account after account switching, logout, or client removal. - * - * Use this for regular Metro ViewModels that do not need runtime screen arguments. Pass [key] only - * when the same ViewModel class can have multiple instances inside one scope, for example one - * instance per tab, conversation, or picker target. - */ -@Composable -inline fun sessionKeyedMetroViewModel( - key: String? = null, - previewProvider: PreviewProvider = EmptyPreviewProvider, - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, -): VM where VM : ViewModel = previewProvider.findPreviewOr { - metroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = sessionKeyedMetroViewModelKey( - defaultKey = VM::class.qualifiedName, - key = key, - scopeKey = LocalWireViewModelScopeKey.current, - ), - ) -} - -/** - * Creates a Metro-backed ViewModel and exposes it as a narrower interface type. - * - * This mirrors the legacy Resaca helper shape where previews could use generated - * `@ViewModelScopedPreview` objects for the interface while runtime still creates the real - * ViewModel implementation. Prefer this overload when the screen depends on a ViewModel - * interface and has a generated preview implementation. - */ -@Composable -@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") -inline fun sessionKeyedMetroViewModelAs( - key: String? = null, - previewProvider: PreviewProvider = EmptyPreviewProvider, - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, -): S where VM : ViewModel, VM : S = previewProvider.findPreviewOr { - metroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = sessionKeyedMetroViewModelKey( - defaultKey = VM::class.qualifiedName, - key = key, - scopeKey = LocalWireViewModelScopeKey.current, - ), - ) -} - -/** - * Creates an assisted Metro-backed ViewModel using the current Wire ViewModel scope key. - * - * This has the same account/session isolation guarantees as [sessionKeyedMetroViewModel], but it also - * lets the caller pass runtime screen arguments through a [ManualViewModelAssistedFactory]. - * - * Use this when the ViewModel constructor needs values that are only known by the screen route or - * call site, such as conversation id, selected folder id, login arguments, or tab type. - */ -@Composable -inline fun sessionKeyedAssistedMetroViewModel( - key: String? = null, - previewProvider: PreviewProvider = EmptyPreviewProvider, - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, - crossinline createViewModel: Factory.() -> VM, -): VM where VM : ViewModel, Factory : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { - assistedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = sessionKeyedMetroViewModelKey( - defaultKey = VM::class.qualifiedName, - key = key, - scopeKey = LocalWireViewModelScopeKey.current, - ), - ) { - createViewModel() - } -} - -/** - * Creates an assisted Metro-backed ViewModel and exposes it as a narrower interface type. - * - * Use this overload for ViewModels with runtime screen arguments when previews should keep using - * generated `@ViewModelScopedPreview` interface implementations. At runtime the real [VM] is still - * created through MetroX and the provided [Factory]. - */ -@Composable -@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") -inline fun sessionKeyedAssistedMetroViewModelAs( - key: String? = null, - previewProvider: PreviewProvider = EmptyPreviewProvider, - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, - crossinline createViewModel: Factory.() -> VM, -): S where VM : ViewModel, VM : S, Factory : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { - assistedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = sessionKeyedMetroViewModelKey( - defaultKey = VM::class.qualifiedName, - key = key, - scopeKey = LocalWireViewModelScopeKey.current, - ), - ) { - createViewModel() - } -} diff --git a/core/di/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModel.kt b/core/di/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModel.kt new file mode 100644 index 00000000000..ebcfe383d0d --- /dev/null +++ b/core/di/src/main/kotlin/com/wire/android/di/metro/WireMetroViewModel.kt @@ -0,0 +1,177 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.di.metro + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.wire.android.di.EmptyPreviewProvider +import com.wire.android.di.PreviewProvider +import com.wire.android.di.findPreviewOr +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +/** + * Builds the identity of one ViewModel instance inside its [ViewModelStoreOwner]. + * + * Owner, session and Metro graph identity deliberately do not participate in this key. The owner + * defines lifetime; this key only distinguishes the ViewModel class and, when explicitly needed, + * multiple immutable instances of that class inside the same owner. + */ +fun wireViewModelInstanceKey( + viewModelClassName: String, + instanceKey: String? = null, +): String { + require(viewModelClassName.isNotBlank()) { "ViewModel class name cannot be blank" } + return instanceKey?.let { "$viewModelClassName:$it" } ?: viewModelClassName +} + +inline fun wireViewModelInstanceKey( + instanceKey: String? = null, +): String = wireViewModelInstanceKey( + viewModelClassName = checkNotNull(VM::class.qualifiedName) { + "ViewModel classes used by Metro must have a qualified name" + }, + instanceKey = instanceKey, +) + +/** + * The single gateway for creating a regular Metro-backed ViewModel. + */ +@Composable +inline fun wireMetroViewModel( + instanceKey: String? = null, + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + previewProvider: PreviewProvider = EmptyPreviewProvider, +): VM = previewProvider.findPreviewOr { + wireMetroViewModelWithResolvedKey( + owner = owner, + resolvedKey = wireViewModelInstanceKey(instanceKey), + ) +} + +/** + * Activity/service entry-point variant of the same Metro ViewModel gateway. + * + * Platform coordinators that must obtain a ViewModel before Compose starts pass the graph factory + * explicitly. Identity and diagnostics remain identical to the composable gateway. + */ +inline fun wireMetroViewModel( + owner: ViewModelStoreOwner, + factory: MetroViewModelFactory, + instanceKey: String? = null, +): VM { + val resolvedKey = wireViewModelInstanceKey(instanceKey) + WireViewModelDiagnostics.viewModelRequested(owner, resolvedKey) + return ViewModelProvider(owner, factory)[resolvedKey, VM::class.java] +} + +/** + * Interface-exposing overload used while generated preview implementations target an interface. + */ +@Composable +@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") +inline fun wireMetroViewModelAs( + instanceKey: String? = null, + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + previewProvider: PreviewProvider = EmptyPreviewProvider, +): S where VM : ViewModel, VM : S = previewProvider.findPreviewOr { + wireMetroViewModelWithResolvedKey( + owner = owner, + resolvedKey = wireViewModelInstanceKey(instanceKey), + ) +} + +/** + * The single gateway for creating a manually-assisted Metro-backed ViewModel. + */ +@Composable +inline fun < + reified VM : ViewModel, + reified Factory : ManualViewModelAssistedFactory, +> wireAssistedMetroViewModel( + instanceKey: String? = null, + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + previewProvider: PreviewProvider = EmptyPreviewProvider, + crossinline create: Factory.(CreationExtras) -> VM, +): VM = previewProvider.findPreviewOr { + wireAssistedMetroViewModelWithResolvedKey( + owner = owner, + resolvedKey = wireViewModelInstanceKey(instanceKey), + create = create, + ) +} + +/** + * Assisted interface-exposing overload used while previews target a ViewModel interface. + */ +@Composable +@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") +inline fun < + reified VM, + reified S, + reified Factory : ManualViewModelAssistedFactory, +> wireAssistedMetroViewModelAs( + instanceKey: String? = null, + owner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + previewProvider: PreviewProvider = EmptyPreviewProvider, + crossinline create: Factory.(CreationExtras) -> VM, +): S where VM : ViewModel, VM : S = previewProvider.findPreviewOr { + wireAssistedMetroViewModelWithResolvedKey( + owner = owner, + resolvedKey = wireViewModelInstanceKey(instanceKey), + create = create, + ) +} + +@Composable +@PublishedApi +internal inline fun wireMetroViewModelWithResolvedKey( + owner: ViewModelStoreOwner, + resolvedKey: String?, +): VM { + WireViewModelDiagnostics.viewModelRequested(owner, checkNotNull(resolvedKey)) + return metroViewModel( + viewModelStoreOwner = owner, + key = resolvedKey, + ) +} + +@Composable +@PublishedApi +internal inline fun < + reified VM : ViewModel, + reified Factory : ManualViewModelAssistedFactory, +> wireAssistedMetroViewModelWithResolvedKey( + owner: ViewModelStoreOwner, + resolvedKey: String?, + crossinline create: Factory.(CreationExtras) -> VM, +): VM { + WireViewModelDiagnostics.viewModelRequested(owner, checkNotNull(resolvedKey)) + return assistedMetroViewModel( + viewModelStoreOwner = owner, + key = resolvedKey, + ) { extras -> + create(extras) + } +} diff --git a/core/di/src/main/kotlin/com/wire/android/di/metro/WireViewModelDiagnostics.kt b/core/di/src/main/kotlin/com/wire/android/di/metro/WireViewModelDiagnostics.kt new file mode 100644 index 00000000000..0052a9bc032 --- /dev/null +++ b/core/di/src/main/kotlin/com/wire/android/di/metro/WireViewModelDiagnostics.kt @@ -0,0 +1,63 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.di.metro + +import androidx.lifecycle.ViewModelStoreOwner + +/** + * Optional diagnostics boundary for the Navigation 3 / Metro ViewModel runtime. + * + * The gateway remains independent from the application logger. Debug-capable hosts may install a + * sink and associate opaque Android owners with their stable Wire owner keys. + */ +interface WireViewModelDiagnosticSink { + fun ownerAvailable(owner: ViewModelStoreOwner, ownerKey: String) + fun ownerReleased(owner: ViewModelStoreOwner, ownerKey: String) + fun ownerCleared(ownerKey: String) + fun viewModelRequested(owner: ViewModelStoreOwner, viewModelKey: String) +} + +object WireViewModelDiagnostics { + private val lock = Any() + + @Volatile + private var sink: WireViewModelDiagnosticSink? = null + + /** + * Installs [diagnosticSink] and returns a handle that only removes that exact installation. + */ + fun install(diagnosticSink: WireViewModelDiagnosticSink): AutoCloseable { + synchronized(lock) { + sink = diagnosticSink + } + return AutoCloseable { + synchronized(lock) { + if (sink === diagnosticSink) sink = null + } + } + } + + fun ownerAvailable(owner: ViewModelStoreOwner, ownerKey: String) { + sink?.ownerAvailable(owner, ownerKey) + } + + fun ownerReleased(owner: ViewModelStoreOwner, ownerKey: String) { + sink?.ownerReleased(owner, ownerKey) + } + + fun ownerCleared(ownerKey: String) { + sink?.ownerCleared(ownerKey) + } + + @PublishedApi + internal fun viewModelRequested(owner: ViewModelStoreOwner, viewModelKey: String) { + sink?.viewModelRequested(owner, viewModelKey) + } +} diff --git a/core/di/src/test/kotlin/com/wire/android/di/metro/WireMetroViewModelTest.kt b/core/di/src/test/kotlin/com/wire/android/di/metro/WireMetroViewModelTest.kt new file mode 100644 index 00000000000..75587f5ad88 --- /dev/null +++ b/core/di/src/test/kotlin/com/wire/android/di/metro/WireMetroViewModelTest.kt @@ -0,0 +1,85 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.di.metro + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireMetroViewModelTest { + + @Test + fun givenNoInstanceKey_whenBuildingViewModelKey_thenOnlyClassDefinesIdentity() { + assertEquals( + "com.wire.ExampleViewModel", + wireViewModelInstanceKey("com.wire.ExampleViewModel"), + ) + } + + @Test + fun givenInstanceKey_whenBuildingViewModelKey_thenClassAndInstanceDefineIdentity() { + assertEquals( + "com.wire.ExampleViewModel:conversation-1", + wireViewModelInstanceKey("com.wire.ExampleViewModel", "conversation-1"), + ) + } + + @Test + fun givenBlankClassName_whenBuildingViewModelKey_thenInputIsRejected() { + assertThrows(IllegalArgumentException::class.java) { + wireViewModelInstanceKey("", "conversation-1") + } + } + + @Test + fun givenCoexistenceRuntime_whenInspectingCreationCalls_thenOnlyNewAndLegacyGatewaysCallMetroXDirectly() { + val metroDirectory = mainMetroDirectory() + val directCreationPattern = Regex("""\b(?:assistedMetroViewModel|metroViewModel)\s*(?:<|\()""") + val offenders = metroDirectory.walkTopDown() + .filter { it.isFile && it.extension == "kt" && it.name !in ALLOWED_GATEWAY_FILES } + .filter { directCreationPattern.containsMatchIn(it.readText()) } + .map { it.name } + .toList() + + assertTrue( + offenders.isEmpty(), + "Direct Metro ViewModel creation outside coexistence gateways $ALLOWED_GATEWAY_FILES: $offenders", + ) + } + + @Test + fun givenAssistedGateway_whenInspectingFactoryContract_thenCreationExtrasArePreserved() { + val gateway = File(mainMetroDirectory(), GATEWAY_FILE).readText() + + assertTrue(gateway.contains("Factory.(CreationExtras) -> VM")) + assertTrue(gateway.contains("create(extras)")) + } + + private fun mainMetroDirectory(): File { + return File(repositoryRoot(), "core/di/src/main/kotlin/com/wire/android/di/metro") + } + + private fun repositoryRoot(): File { + val userDirectory = checkNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "core/di/src/main/kotlin").isDirectory } + } + + private companion object { + const val GATEWAY_FILE = "WireMetroViewModel.kt" + val ALLOWED_GATEWAY_FILES = setOf( + GATEWAY_FILE, + // Kept deliberately until vertical migrations and the final cleanup branch. + "MetroViewModelGraph.kt", + ) + } +} diff --git a/core/di/src/test/kotlin/com/wire/android/di/metro/WireViewModelDiagnosticsTest.kt b/core/di/src/test/kotlin/com/wire/android/di/metro/WireViewModelDiagnosticsTest.kt new file mode 100644 index 00000000000..ce39be63f1f --- /dev/null +++ b/core/di/src/test/kotlin/com/wire/android/di/metro/WireViewModelDiagnosticsTest.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.di.metro + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WireViewModelDiagnosticsTest { + private val installations = mutableListOf() + + @AfterEach + fun tearDown() { + installations.reversed().forEach(AutoCloseable::close) + } + + @Test + fun givenSinkIsInstalled_whenRuntimeEventsOccur_thenOwnerAndViewModelIdentityAreForwarded() { + val events = mutableListOf() + val sink = RecordingSink(events) + val owner = TestOwner() + installations += WireViewModelDiagnostics.install(sink) + + WireViewModelDiagnostics.ownerAvailable(owner, "flow:login") + WireViewModelDiagnostics.viewModelRequested(owner, "com.wire.LoginViewModel") + WireViewModelDiagnostics.ownerReleased(owner, "flow:login") + WireViewModelDiagnostics.ownerCleared("flow:login") + + assertEquals( + listOf( + "available:flow:login", + "viewModel:com.wire.LoginViewModel", + "released:flow:login", + "cleared:flow:login", + ), + events, + ) + } + + @Test + fun givenSinkWasReplaced_whenOldInstallationCloses_thenCurrentSinkRemainsInstalled() { + val oldEvents = mutableListOf() + val currentEvents = mutableListOf() + val oldInstallation = + WireViewModelDiagnostics.install(RecordingSink(oldEvents)) + installations += oldInstallation + installations += WireViewModelDiagnostics.install(RecordingSink(currentEvents)) + + oldInstallation.close() + WireViewModelDiagnostics.ownerCleared("flow:login") + + assertEquals(emptyList(), oldEvents) + assertEquals(listOf("cleared:flow:login"), currentEvents) + } + + private class TestOwner : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + + private class RecordingSink( + private val events: MutableList, + ) : WireViewModelDiagnosticSink { + override fun ownerAvailable(owner: ViewModelStoreOwner, ownerKey: String) { + events += "available:$ownerKey" + } + + override fun ownerReleased(owner: ViewModelStoreOwner, ownerKey: String) { + events += "released:$ownerKey" + } + + override fun ownerCleared(ownerKey: String) { + events += "cleared:$ownerKey" + } + + override fun viewModelRequested(owner: ViewModelStoreOwner, viewModelKey: String) { + events += "viewModel:$viewModelKey" + } + } +} diff --git a/core/di/stability/di-debug.stability b/core/di/stability/di-debug.stability index 644e997877d..b198462b3f6 100644 --- a/core/di/stability/di-debug.stability +++ b/core/di/stability/di-debug.stability @@ -25,78 +25,77 @@ internal fun com.wire.android.di.manualScopedViewModelFactory(create: @[Extensio - create: STABLE (function type) @Composable -public fun com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, createViewModel: @[ExtensionFunctionType] kotlin.Function1): VM of com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel +public fun com.wire.android.di.metro.wireAssistedMetroViewModel(instanceKey: kotlin.String?, owner: androidx.lifecycle.ViewModelStoreOwner, previewProvider: com.wire.android.di.PreviewProvider, create: @[ExtensionFunctionType] kotlin.Function2): VM of com.wire.android.di.metro.wireAssistedMetroViewModel skippable: false restartable: true params: - - key: STABLE (class with no mutable properties) + - instanceKey: STABLE (class with no mutable properties) + - owner: RUNTIME (requires runtime check) - previewProvider: STABLE (marked @Stable or @Immutable) - - viewModelStoreOwner: RUNTIME (requires runtime check) - - createViewModel: STABLE (function type) + - create: STABLE (function type) @Composable -public fun com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, createViewModel: @[ExtensionFunctionType] kotlin.Function1): S of com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs +public fun com.wire.android.di.metro.wireAssistedMetroViewModelAs(instanceKey: kotlin.String?, owner: androidx.lifecycle.ViewModelStoreOwner, previewProvider: com.wire.android.di.PreviewProvider, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.di.metro.wireAssistedMetroViewModelAs skippable: false restartable: true params: - - key: STABLE (class with no mutable properties) + - instanceKey: STABLE (class with no mutable properties) + - owner: RUNTIME (requires runtime check) - previewProvider: STABLE (marked @Stable or @Immutable) - - viewModelStoreOwner: RUNTIME (requires runtime check) - - createViewModel: STABLE (function type) + - create: STABLE (function type) @Composable -public fun com.wire.android.di.metro.sessionKeyedMetroViewModel(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): VM of com.wire.android.di.metro.sessionKeyedMetroViewModel +internal fun com.wire.android.di.metro.wireAssistedMetroViewModelWithResolvedKey(owner: androidx.lifecycle.ViewModelStoreOwner, resolvedKey: kotlin.String?, create: @[ExtensionFunctionType] kotlin.Function2): VM of com.wire.android.di.metro.wireAssistedMetroViewModelWithResolvedKey skippable: false restartable: true params: - - key: STABLE (class with no mutable properties) - - previewProvider: STABLE (marked @Stable or @Immutable) - - viewModelStoreOwner: RUNTIME (requires runtime check) + - owner: RUNTIME (requires runtime check) + - resolvedKey: STABLE (class with no mutable properties) + - create: STABLE (function type) @Composable -public fun com.wire.android.di.metro.sessionKeyedMetroViewModelAs(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): S of com.wire.android.di.metro.sessionKeyedMetroViewModelAs +public fun com.wire.android.di.metro.wireMetroViewModel(instanceKey: kotlin.String?, owner: androidx.lifecycle.ViewModelStoreOwner, previewProvider: com.wire.android.di.PreviewProvider): VM of com.wire.android.di.metro.wireMetroViewModel skippable: false restartable: true params: - - key: STABLE (class with no mutable properties) + - instanceKey: STABLE (class with no mutable properties) + - owner: RUNTIME (requires runtime check) - previewProvider: STABLE (marked @Stable or @Immutable) - - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.di.wireManualMetroViewModelScoped(arguments: R of com.wire.android.di.wireManualMetroViewModelScoped, previewProvider: com.wire.android.di.PreviewProvider, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function3): S of com.wire.android.di.wireManualMetroViewModelScoped +public fun com.wire.android.di.metro.wireMetroViewModelAs(instanceKey: kotlin.String?, owner: androidx.lifecycle.ViewModelStoreOwner, previewProvider: com.wire.android.di.PreviewProvider): S of com.wire.android.di.metro.wireMetroViewModelAs skippable: false restartable: true params: - - arguments: RUNTIME (requires runtime check) + - instanceKey: STABLE (class with no mutable properties) + - owner: RUNTIME (requires runtime check) - previewProvider: STABLE (marked @Stable or @Immutable) - - clearDelay: STABLE (known stable type) - - create: STABLE (function type) @Composable -public fun com.wire.android.di.wireManualMetroViewModelScoped(arguments: R of com.wire.android.di.wireManualMetroViewModelScoped, keyInScopeResolver: kotlin.Function1<@[ParameterName(name = \, previewProvider: com.wire.android.di.PreviewProvider, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function3): S of com.wire.android.di.wireManualMetroViewModelScoped +internal fun com.wire.android.di.metro.wireMetroViewModelWithResolvedKey(owner: androidx.lifecycle.ViewModelStoreOwner, resolvedKey: kotlin.String?): VM of com.wire.android.di.metro.wireMetroViewModelWithResolvedKey skippable: false restartable: true params: - - arguments: RUNTIME (requires runtime check) - - keyInScopeResolver: STABLE (function type) - - previewProvider: STABLE (marked @Stable or @Immutable) - - clearDelay: STABLE (known stable type) - - create: STABLE (function type) + - owner: RUNTIME (requires runtime check) + - resolvedKey: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.di.wireManualMetroViewModelScoped(previewProvider: com.wire.android.di.PreviewProvider, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.di.wireManualMetroViewModelScoped - skippable: true +public fun com.wire.android.di.wireManualMetroViewModelScoped(arguments: R of com.wire.android.di.wireManualMetroViewModelScoped, previewProvider: com.wire.android.di.PreviewProvider, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.di.wireManualMetroViewModelScoped + skippable: false restartable: true params: + - arguments: RUNTIME (requires runtime check) - previewProvider: STABLE (marked @Stable or @Immutable) - clearDelay: STABLE (known stable type) - create: STABLE (function type) @Composable -public fun com.wire.android.di.wireManualMetroViewModelScoped(previewProvider: com.wire.android.di.PreviewProvider, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): T of com.wire.android.di.wireManualMetroViewModelScoped - skippable: true +public fun com.wire.android.di.wireManualMetroViewModelScoped(arguments: R of com.wire.android.di.wireManualMetroViewModelScoped, keyInScopeResolver: kotlin.Function1<@[ParameterName(name = \, previewProvider: com.wire.android.di.PreviewProvider, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.di.wireManualMetroViewModelScoped + skippable: false restartable: true params: + - arguments: RUNTIME (requires runtime check) + - keyInScopeResolver: STABLE (function type) - previewProvider: STABLE (marked @Stable or @Immutable) - clearDelay: STABLE (known stable type) - create: STABLE (function type) diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt index 7f4bc12bfc6..08e9b70a125 100644 --- a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt @@ -20,7 +20,7 @@ package com.wire.android.mediaplayer import androidx.compose.runtime.Composable -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface MediaPlayerManualViewModelFactory : ManualViewModelAssistedFactory { @@ -37,8 +37,8 @@ fun videoPlayerViewModel( contentUrl: String?, fileName: String?, ): VideoPlayerViewModel = - sessionKeyedAssistedMetroViewModel( - key = "video_player_${localPath ?: contentUrl}" + wireAssistedMetroViewModel( + instanceKey = "video_player_${localPath ?: contentUrl}" ) { videoPlayerViewModel(localPath, contentUrl, fileName) } diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt index 345fc77a751..8a6fb24e92e 100644 --- a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt @@ -41,7 +41,7 @@ import java.io.File /** * Plays a single video from either a local file ([localPath]) or a remote URL ([contentUrl]). * - * The screen arguments are passed in through assisted injection + * The screen arguments are passed in through the per-ViewModel assisted [Factory] * rather than read from a navigation destination, so the player can be reused from any module. */ class VideoPlayerViewModel @AssistedInject constructor( diff --git a/core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt b/core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt new file mode 100644 index 00000000000..dfea9ff9828 --- /dev/null +++ b/core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt @@ -0,0 +1,37 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.wire.android.mediaplayer + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class MediaPlayerAssistedFactorySourceTest { + + @Test + fun givenVideoRouteArguments_whenInspectingCreation_thenNarrowAssistedFactoryOwnsOnlyThoseArguments() { + val viewModel = source("VideoPlayerViewModel.kt") + val bindings = source("MediaPlayerMetroViewModelBindings.kt") + + assertTrue(viewModel.contains("class VideoPlayerViewModel @AssistedInject constructor")) + assertTrue(viewModel.contains("@ApplicationContext context: Context")) + assertTrue(viewModel.contains("@Assisted val localPath: String?")) + assertTrue(viewModel.contains("@Assisted val contentUrl: String?")) + assertTrue(viewModel.contains("@Assisted val fileName: String?")) + assertTrue(viewModel.contains("@AssistedFactory\n interface Factory")) + assertTrue(bindings.contains("factory.create(localPath, contentUrl, fileName)")) + assertFalse(bindings.contains("MediaPlayerViewModelFactory")) + assertFalse(File("src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelFactory.kt").exists()) + } + + private fun source(name: String) = + File("src/main/kotlin/com/wire/android/mediaplayer/$name").readText() +} diff --git a/core/navigation-kmp/build.gradle.kts b/core/navigation-kmp/build.gradle.kts new file mode 100644 index 00000000000..9206910dce8 --- /dev/null +++ b/core/navigation-kmp/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id(libs.plugins.wire.kmp.library.get().pluginId) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + android { + namespace = "com.wire.navigation" + } + + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.compose.multiplatform.navigation3.runtime) + implementation(libs.ktx.serialization) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireBackStackReducer.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireBackStackReducer.kt new file mode 100644 index 00000000000..08db2a7bae9 --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireBackStackReducer.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +/** + * Applies Wire navigation semantics without depending on Android, Compose, or a navigation host. + * + * Navigation 3 can apply the returned list directly to its application-owned back stack. Keeping + * this operation pure makes existing back-stack behavior independently testable. + */ +fun reduceBackStack( + backStack: List, + command: WireNavigationCommand, +): List { + val reduced = backStack.toMutableList() + + when (command.backStackMode) { + WireBackStackMode.CLEAR_TILL_START -> { + if (reduced.size > 1) { + reduced.subList(1, reduced.size).clear() + } + } + + WireBackStackMode.CLEAR_WHOLE -> reduced.clear() + WireBackStackMode.REMOVE_CURRENT -> reduced.removeLastOrNull() + WireBackStackMode.REMOVE_CURRENT_NESTED_FLOW -> reduced.removeCurrentFlow() + WireBackStackMode.UPDATE_EXISTING -> reduced.removeFromFirst(command.destination.routeId) + WireBackStackMode.REMOVE_CURRENT_AND_REPLACE -> { + reduced.removeLastOrNull() + reduced.removeFromFirst(command.destination.routeId) + } + + WireBackStackMode.POP_CONSECUTIVE_SAME_ROUTES -> reduced.removeConsecutiveRoutesFromTop() + WireBackStackMode.NONE -> Unit + } + + reduced.addOrReplaceSingleTop(command) + return reduced +} + +private fun MutableList.removeCurrentFlow() { + val currentFlowId = lastOrNull()?.flowId ?: return + while (lastOrNull()?.flowId == currentFlowId) { + removeLast() + } +} + +private fun MutableList.removeFromFirst(routeId: String) { + val existingIndex = indexOfFirst { it.routeId == routeId } + if (existingIndex >= 0) { + subList(existingIndex, size).clear() + } +} + +private fun MutableList.removeConsecutiveRoutesFromTop() { + val currentRouteId = lastOrNull()?.routeId ?: return + while (lastOrNull()?.routeId == currentRouteId) { + removeLast() + } +} + +private fun MutableList.addOrReplaceSingleTop(command: WireNavigationCommand) { + if (command.launchSingleTop && lastOrNull()?.routeId == command.destination.routeId) { + this[lastIndex] = command.destination + } else { + add(command.destination) + } +} diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireDeepLinkResolver.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireDeepLinkResolver.kt new file mode 100644 index 00000000000..fbd9303c604 --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireDeepLinkResolver.kt @@ -0,0 +1,128 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlinx.serialization.Serializable + +/** + * Platform-independent input to Wire's typed deep-link resolvers. + * + * Platform code remains responsible for deciding whether an Android Intent, iOS URL, or another + * external event is a deep link. Keeping the original value here allows a deferred link to be + * replayed after authentication or another prerequisite without converting it to Android types. + */ +@Serializable +data class WireDeepLinkRequest(val value: String) + +/** + * Terminal result of resolving a deep link. + * + * There is deliberately no "use the default route" outcome. Callers must handle unsupported and + * invalid links explicitly instead of silently opening an unrelated screen. + */ +sealed interface WireDeepLinkResolution { + + /** + * A complete, typed stack to install in the Navigation 3 runtime. + */ + data class Resolved(val routes: List) : WireDeepLinkResolution { + init { + require(routes.isNotEmpty()) { "A resolved deep-link stack cannot be empty" } + require(routes.map(WireRoute::entryId).distinct().size == routes.size) { + "A resolved deep-link stack cannot contain duplicate entry ids" + } + } + } + + /** + * The link is known, but its destination requires an authenticated session. + */ + data class AuthenticationRequired( + val pendingRequest: WireDeepLinkRequest, + ) : WireDeepLinkResolution + + /** + * The link is known, but app-owned work must finish before routes can be produced. + */ + data class Deferred( + val pendingRequest: WireDeepLinkRequest, + val reason: WireDeepLinkDeferredReason, + ) : WireDeepLinkResolution + + /** + * The input is structurally valid but no registered resolver owns it. + */ + data object Unsupported : WireDeepLinkResolution + + /** + * The input is empty or a resolver recognized it but rejected its structure or arguments. + */ + data class Invalid(val reason: WireDeepLinkInvalidReason) : WireDeepLinkResolution +} + +/** + * Cross-platform categories only. Concrete account switching, SSO, and validation state stays in + * the app-owned resolver that returns one of these categories. + */ +enum class WireDeepLinkDeferredReason { + SESSION_TRANSITION, + EXTERNAL_FLOW, + BUSINESS_VALIDATION, +} + +enum class WireDeepLinkInvalidReason { + EMPTY_INPUT, + MALFORMED, + MISSING_REQUIRED_ARGUMENT, + INVALID_ARGUMENT, +} + +/** + * A resolver either declines an input or claims it with a terminal result. + * + * Once claimed, even [WireDeepLinkResolution.Invalid] is terminal. This prevents a malformed link + * for a high-priority route from falling through to a broader resolver. + */ +fun interface WireDeepLinkResolver { + fun resolve(request: WireDeepLinkRequest): WireDeepLinkResolverResult +} + +sealed interface WireDeepLinkResolverResult { + data object NotMatched : WireDeepLinkResolverResult + + data class Matched( + val resolution: WireDeepLinkResolution, + ) : WireDeepLinkResolverResult +} + +/** + * Resolves links in registration order. Earlier resolvers have deterministic precedence. + */ +class WireDeepLinkResolverChain( + private val resolvers: List, +) { + fun resolve(request: WireDeepLinkRequest): WireDeepLinkResolution = + if (request.value.isBlank()) { + WireDeepLinkResolution.Invalid(WireDeepLinkInvalidReason.EMPTY_INPUT) + } else { + resolvers.firstNotNullOfOrNull { resolver -> + (resolver.resolve(request) as? WireDeepLinkResolverResult.Matched)?.resolution + } ?: WireDeepLinkResolution.Unsupported + } +} diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResult.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResult.kt new file mode 100644 index 00000000000..500a99a94eb --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResult.kt @@ -0,0 +1,140 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlin.jvm.JvmInline +import kotlin.random.Random +import kotlinx.serialization.Serializable + +private const val ENTRY_ID_BYTE_COUNT = 16 +private const val HEX_RADIX = 16 +private const val HEX_BYTE_WIDTH = 2 + +/** + * Opaque identity of one concrete entry on the back stack. + * + * This must be unique for every entry instance, including two entries created from the same + * [WireRoute]. A route id is consequently not a valid entry id. + */ +@Serializable +@JvmInline +value class WireNavEntryId(val value: String) { + init { + require(value.isNotBlank()) { "A navigation entry id cannot be blank" } + } + + companion object { + /** + * Creates an opaque entry identity once, before a route enters the saveable back stack. + */ + fun random(random: Random = Random.Default): WireNavEntryId { + val bytes = ByteArray(ENTRY_ID_BYTE_COUNT) + random.nextBytes(bytes) + return WireNavEntryId( + bytes.joinToString(separator = "") { byte -> + byte.toUByte().toString(radix = HEX_RADIX).padStart(HEX_BYTE_WIDTH, '0') + } + ) + } + } +} + +/** + * Opaque identity of one result request. + * + * The identity is supplied by the runtime so it can be generated once and restored together with + * the back stack. + */ +@Serializable +@JvmInline +value class WireNavResultRequestId(val value: String) { + init { + require(value.isNotBlank()) { "A navigation result request id cannot be blank" } + } +} + +/** + * Saveable identity of a typed result contract. + */ +@Serializable +@JvmInline +value class WireNavResultContractId(val value: String) { + init { + require(value.isNotBlank()) { "A navigation result contract id cannot be blank" } + } +} + +/** + * Runtime type token for a navigation result channel. + * + * Only [id] enters saved state. Serializers remain a concern of the persistence boundary, which + * allows the common model to support multiple heterogeneous contracts without storing serializer + * objects or platform-specific type information. + */ +class WireNavResultContract(val id: WireNavResultContractId) + +/** + * The terminal outcome of a typed navigation result request. + * + * [Value] intentionally accepts nullable values. This keeps `Value(null)` distinct from both + * [Canceled] and a request that has not completed yet. + */ +@Serializable +sealed interface WireNavResult { + @Serializable + data class Value(val value: T) : WireNavResult + + @Serializable + data object Canceled : WireNavResult +} + +/** + * Stable addressing for a result traveling from [targetEntryId] back to [requesterEntryId]. + */ +@Serializable +data class WireNavResultRequest( + val id: WireNavResultRequestId, + val contractId: WireNavResultContractId, + val requesterEntryId: WireNavEntryId, + val targetEntryId: WireNavEntryId, +) { + init { + require(requesterEntryId != targetEntryId) { + "Requester and target must be different navigation entries" + } + } +} + +/** + * Saveable state of a request. A completed result remains here until it is consumed exactly once. + */ +@Serializable +sealed interface WireNavResultState { + @Serializable + data object Pending : WireNavResultState + + @Serializable + data class Completed(val result: WireNavResult) : WireNavResultState +} + +@Serializable +data class WireNavResultRecord( + val request: WireNavResultRequest, + val state: WireNavResultState = WireNavResultState.Pending, +) diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResultRegistry.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResultRegistry.kt new file mode 100644 index 00000000000..972a99dc5b6 --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavResultRegistry.kt @@ -0,0 +1,242 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Serializable snapshot used to restore outstanding requests after recreation. + */ +@Serializable +data class WireNavResultRegistrySnapshot( + val records: List> = emptyList(), +) + +/** + * Encodes one typed channel without leaking JSON into the platform runtime. + */ +fun WireNavResultRegistrySnapshot.encode( + valueSerializer: KSerializer, +): String = Json.encodeToString( + serializer = WireNavResultRegistrySnapshot.serializer(valueSerializer), + value = this, +) + +/** + * Restores one typed channel at the platform persistence boundary. + */ +fun decodeWireNavResultRegistrySnapshot( + encoded: String, + valueSerializer: KSerializer, +): WireNavResultRegistrySnapshot = Json.decodeFromString( + deserializer = WireNavResultRegistrySnapshot.serializer(valueSerializer), + string = encoded, +) + +/** + * Platform-independent state machine for one typed result channel. + * + * Mutation is intentionally centralized here. [complete] performs one Pending -> Completed + * transition and rejects every later producer, while [consume] removes a completed record before + * returning it. The owning navigation runtime must serialize calls to this registry on its state + * thread, just like mutations of the application-owned back stack. + */ +class WireNavResultRegistry( + val contract: WireNavResultContract, + restored: WireNavResultRegistrySnapshot = WireNavResultRegistrySnapshot(), +) { + private val records = restored.records.associateByTo(mutableMapOf()) { it.request.id } + + init { + require(records.size == restored.records.size) { + "A restored navigation result snapshot contains duplicate request ids" + } + require(records.values.all { it.request.contractId == contract.id }) { + "A restored navigation result snapshot contains a different result contract" + } + } + + fun snapshot(): WireNavResultRegistrySnapshot = + WireNavResultRegistrySnapshot(records.values.toList()) + + /** + * Registers a request before its target entry is shown. + * + * Reusing a request id is rejected, even if the entries happen to be identical. + */ + fun register(request: WireNavResultRequest): Boolean { + require(request.contractId == contract.id) { + "A navigation result request belongs to a different result contract" + } + if (request.id in records) return false + records[request.id] = WireNavResultRecord(request) + return true + } + + /** + * Atomically claims completion of a pending request. + * + * Returns false for an unknown or already completed request, ensuring that only the first + * result producer wins. + */ + fun complete(requestId: WireNavResultRequestId, result: WireNavResult): Boolean { + val record = records[requestId] + return if (record?.state is WireNavResultState.Pending) { + records[requestId] = record.copy(state = WireNavResultState.Completed(result)) + true + } else { + false + } + } + + /** + * Rolls back a request that could not create its target entry. + * + * Only a still-pending request can be discarded; terminal results remain consumable. + */ + fun discardPending(requestId: WireNavResultRequestId): Boolean { + val record = records[requestId] + return if (record?.state is WireNavResultState.Pending) { + records.remove(requestId) + true + } else { + false + } + } + + /** + * Returns a completed result to its requester at most once. + * + * The requester identity is checked so another copy of the same route cannot consume it. + */ + fun consume( + requestId: WireNavResultRequestId, + requesterEntryId: WireNavEntryId, + ): WireNavResult? { + val record = records[requestId] + val completed = record?.state as? WireNavResultState.Completed + val isRequester = record?.request?.requesterEntryId == requesterEntryId + return completed?.takeIf { isRequester }?.also { + records.remove(requestId) + }?.result + } + + /** + * Handles an ordinary pop of [entryId]. + * + * Requests owned by the removed requester are discarded. A pending request targeting the + * removed entry completes as [WireNavResult.Canceled], provided its requester still exists. + */ + fun onEntryRemoved(entryId: WireNavEntryId, remainingEntryIds: Set) { + records.entries.removeAll { (_, record) -> + record.request.requesterEntryId == entryId || + record.request.requesterEntryId !in remainingEntryIds + } + records.keys.toList().forEach { requestId -> + val record = records.getValue(requestId) + if ( + record.request.targetEntryId == entryId && + record.state is WireNavResultState.Pending + ) { + records[requestId] = record.copy( + state = WireNavResultState.Completed(WireNavResult.Canceled), + ) + } + } + } + + /** + * Prunes stale requests after a non-linear stack replacement or session switch. + * + * Unlike [onEntryRemoved], this does not synthesize cancellation: neither side can safely + * observe it once its restored entry relationship has been broken. + */ + fun prune(activeEntryIds: Set) { + records.entries.removeAll { (_, record) -> + record.request.requesterEntryId !in activeEntryIds || + record.request.targetEntryId !in activeEntryIds + } + } + + /** + * Drops all outstanding cross-entry state when the navigation session changes. + */ + fun clear() { + records.clear() + } + + /** + * Resolves the single pending request targeting [targetEntryId]. + * + * The destination does not carry a request id as a navigation argument. A fresh target entry + * normally has exactly one producer relationship; returning `null` for both missing and + * ambiguous relationships keeps a target from completing the wrong caller. + */ + fun uniquePendingRequestForTarget( + targetEntryId: WireNavEntryId, + ): WireNavResultRequestId? { + val matches = records.values.asSequence() + .filter { record -> + record.request.targetEntryId == targetEntryId && + record.state is WireNavResultState.Pending + } + .map { it.request.id } + .take(2) + .toList() + return matches.singleOrNull() + } + + internal fun pendingTarget(requestId: WireNavResultRequestId): WireNavEntryId? { + val record = records[requestId] ?: return null + return if (record.state is WireNavResultState.Pending) { + record.request.targetEntryId + } else { + null + } + } +} + +/** + * Navigation-boundary operation that keeps producing a result and removing its target together. + * + * The runtime must invoke this coordinator on the same serialized state thread as its back-stack + * mutations. [removeTarget] must only remove the supplied entry and must not independently notify + * the registry; reconciliation happens after this operation returns. + */ +class WireNavResultCoordinator( + private val registry: WireNavResultRegistry, + private val removeTarget: (WireNavEntryId) -> Boolean, +) { + fun completeAndPop( + requestId: WireNavResultRequestId, + result: WireNavResult, + ): Boolean { + val targetEntryId = registry.pendingTarget(requestId) + return if (targetEntryId != null && removeTarget(targetEntryId)) { + check(registry.complete(requestId, result)) { + "Navigation result state changed during a serialized complete-and-pop operation" + } + true + } else { + false + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationCommand.kt similarity index 62% rename from app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.kt rename to core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationCommand.kt index 6691593b773..d4ba7174c38 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.kt +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationCommand.kt @@ -15,10 +15,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.conversations.videoplayer -data class VideoPlayerNavArgs( - val localPath: String? = null, - val contentUrl: String? = null, - val fileName: String? = null, +package com.wire.navigation + +data class WireNavigationCommand( + val destination: WireRoute, + val backStackMode: WireBackStackMode = WireBackStackMode.NONE, + val launchSingleTop: Boolean = true, ) + +enum class WireBackStackMode { + CLEAR_TILL_START, + CLEAR_WHOLE, + REMOVE_CURRENT, + REMOVE_CURRENT_NESTED_FLOW, + UPDATE_EXISTING, + REMOVE_CURRENT_AND_REPLACE, + POP_CONSECUTIVE_SAME_ROUTES, + NONE, +} diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationController.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationController.kt new file mode 100644 index 00000000000..b0b5c0f0584 --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireNavigationController.kt @@ -0,0 +1,106 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import androidx.navigation3.runtime.NavKey + +/** + * The single mutation point for an application-owned Navigation 3 back stack. + * + * Navigation 3's saveable back stack is typed as [NavKey]. Wire intentionally narrows every + * mutation to [WireRoute], so foreign keys cannot silently enter the production stack. + */ +class WireNavigationController( + private val backStack: MutableList, + private val canNavigate: (WireNavigationCommand) -> Boolean = { true }, + private val onBackStackChanged: ( + previous: List, + current: List, + change: WireBackStackChange, + ) -> Unit = { _, _, _ -> }, +) { + init { + require(backStack.all { it is WireRoute }) { + "Wire navigation back stack can only contain WireRoute keys" + } + requireUniqueEntryIds(backStack.map { it as WireRoute }) + } + + val currentRoute: WireRoute? + get() = backStack.lastOrNull() as? WireRoute + + val routes: List + get() = backStack.map { it as WireRoute } + + /** + * Applies [command] atomically and reports whether it was accepted. + */ + fun navigate(command: WireNavigationCommand): Boolean { + if (!canNavigate(command)) return false + + val previous = routes + val updatedStack = reduceBackStack(previous, command) + requireUniqueEntryIds(updatedStack) + backStack.clear() + backStack.addAll(updatedStack) + onBackStackChanged(previous, updatedStack, WireBackStackChange.NAVIGATE) + return true + } + + /** + * Pops one route. The start route is retained unless [allowEmpty] is explicitly requested. + */ + fun goBack(allowEmpty: Boolean = false): Boolean { + if (backStack.isEmpty() || (!allowEmpty && backStack.size == 1)) return false + val previous = routes + backStack.removeLast() + onBackStackChanged(previous, routes, WireBackStackChange.BACK) + return true + } + + /** + * Replaces the complete stack, for example after resolving a deep link or session transition. + */ + fun replaceBackStack(routes: List) { + require(routes.isNotEmpty()) { "Wire navigation back stack cannot be replaced with an empty stack" } + requireUniqueEntryIds(routes) + val previous = this.routes + backStack.clear() + backStack.addAll(routes) + onBackStackChanged(previous, routes, WireBackStackChange.REPLACE) + } + + private fun requireUniqueEntryIds(routes: List) { + require(routes.map(WireRoute::entryId).distinct().size == routes.size) { + "Wire navigation back stack cannot contain duplicate entry ids" + } + } +} + +/** + * The intent behind a completed stack mutation. + * + * Result runtimes use this distinction to synthesize cancellation only for an ordinary back and + * to prune broken relationships after non-linear replacement. + */ +enum class WireBackStackChange { + NAVIGATE, + BACK, + REPLACE, +} diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt new file mode 100644 index 00000000000..153974f1c58 --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +/** + * Serializable account identity used to select a session-scoped Metro graph. + */ +@Serializable +data class WireSessionId( + val value: String, + val domain: String, +) { + init { + require(value.isNotBlank()) { "A session id value cannot be blank" } + require(domain.isNotBlank()) { "A session id domain cannot be blank" } + } +} + +/** Stable flow identity for session-backed authentication started outside an existing auth flow. */ +fun WireSessionId.authenticationSessionFlowId(): String = + "authentication-session:$value@$domain" + +/** + * Platform-independent navigation contract. + * + * Concrete routes live next to the feature that owns them. They must be serializable and only + * contain values available to all supported Kotlin Multiplatform targets. + */ +interface WireRoute : NavKey { + /** + * Stable identity of this concrete back-stack entry. + * + * Concrete serializable routes generate this once in their constructor. It is intentionally + * independent from [routeId], so equal destinations can coexist without sharing entry state. + */ + val entryId: WireNavEntryId + + /** + * Stable identity of a destination, excluding its arguments. + * + * This replaces matching generated destination base-route strings. Two route instances with + * different arguments use the same [routeId]. + */ + val routeId: String + + /** + * Optional identity of a flow that owns this route. + * + * Routes with the same non-null value can be removed together when their nested flow is + * completed. It will also become the natural owner key for flow-scoped ViewModels. + */ + val flowId: String? + get() = null +} + +/** + * Marks a route that belongs to the authentication experience for screen observation/analytics. + * + * Session-backed authentication screens implement this together with [SessionRoute]. The marker + * deliberately says nothing about dependency ownership. + */ +interface AuthenticationScreenRoute : WireRoute + +/** + * Marks an authentication screen that does not require an authenticated session graph. + */ +interface AuthenticationRoute : AuthenticationScreenRoute + +/** + * Marks a route whose dependencies belong to a specific account session. + * + * This selects the session-specific dependency graph. It does not change ViewModel ownership; + * session-shared ViewModels require an explicit [WireViewModelOwner.Session] at their call site. + */ +interface SessionRoute : WireRoute { + val sessionId: WireSessionId +} diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireViewModelOwner.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireViewModelOwner.kt new file mode 100644 index 00000000000..e4c525b1dd5 --- /dev/null +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireViewModelOwner.kt @@ -0,0 +1,87 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlinx.serialization.Serializable + +/** + * Stable, platform-independent identity of a ViewModel lifetime. + */ +@Serializable +sealed interface WireViewModelOwner { + + /** + * Owns state for one concrete navigation entry. + */ + @Serializable + data class Entry(val entryId: WireNavEntryId) : WireViewModelOwner + + /** + * Owns state shared by all entries in one explicitly identified flow. + */ + @Serializable + data class Flow(val flowId: String) : WireViewModelOwner { + init { + require(flowId.isNotBlank()) { "A ViewModel flow owner id cannot be blank" } + } + } + + /** + * Owns state deliberately shared by routes for one explicit account session. + */ + @Serializable + data class Session(val sessionId: WireSessionId) : WireViewModelOwner + + /** + * Owns process-wide state. + */ + @Serializable + data object Application : WireViewModelOwner +} + +/** + * Returns this route's default, entry-scoped ViewModel owner. + */ +fun WireRoute.entryViewModelOwner(): WireViewModelOwner.Entry = + WireViewModelOwner.Entry(entryId) + +/** + * Returns the shared owners that a ViewModel hosted by this route may explicitly request. + * + * Merely making an owner available never changes the route's default [entryViewModelOwner]. + */ +fun WireRoute.availableSharedViewModelOwners(): Set = + buildSet { + flowId?.let { add(WireViewModelOwner.Flow(it)) } + if (this@availableSharedViewModelOwners is SessionRoute) { + add(WireViewModelOwner.Session(sessionId)) + } + add(WireViewModelOwner.Application) + } + +/** + * Returns the stable value key used by the platform runtime to retain an owner. + */ +fun WireViewModelOwner.stableKey(): String = + when (this) { + is WireViewModelOwner.Entry -> "entry:${entryId.value}" + is WireViewModelOwner.Flow -> "flow:$flowId" + is WireViewModelOwner.Session -> "session:${sessionId.value}@${sessionId.domain}" + WireViewModelOwner.Application -> "application" + } diff --git a/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireBackStackReducerTest.kt b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireBackStackReducerTest.kt new file mode 100644 index 00000000000..f5ee4d6361f --- /dev/null +++ b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireBackStackReducerTest.kt @@ -0,0 +1,114 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals + +class WireBackStackReducerTest { + + @Test + fun givenExistingStack_whenClearingWholeStack_thenOnlyDestinationRemains() { + val result = reduceBackStack( + backStack = listOf(route("start"), route("details")), + command = command("login", WireBackStackMode.CLEAR_WHOLE), + ) + + assertEquals(listOf(route("login")), result) + } + + @Test + fun givenExistingStack_whenClearingTillStart_thenStartAndDestinationRemain() { + val result = reduceBackStack( + backStack = listOf(route("start"), route("details"), route("profile")), + command = command("settings", WireBackStackMode.CLEAR_TILL_START), + ) + + assertEquals(listOf(route("start"), route("settings")), result) + } + + @Test + fun givenNestedFlowOnTop_whenRemovingCurrentFlow_thenWholeFlowIsRemoved() { + val result = reduceBackStack( + backStack = listOf( + route("home"), + route("settings"), + route("create-name", flowId = "create-conversation"), + route("create-members", flowId = "create-conversation"), + ), + command = command("conversation", WireBackStackMode.REMOVE_CURRENT_NESTED_FLOW), + ) + + assertEquals(listOf(route("home"), route("settings"), route("conversation")), result) + } + + @Test + fun givenDestinationAlreadyExists_whenUpdatingExisting_thenOldDestinationAndFollowingRoutesAreReplaced() { + val result = reduceBackStack( + backStack = listOf(route("home"), route("profile", "old"), route("details")), + command = WireNavigationCommand( + destination = route("profile", "new"), + backStackMode = WireBackStackMode.UPDATE_EXISTING, + ), + ) + + assertEquals(listOf(route("home"), route("profile", "new")), result) + } + + @Test + fun givenConsecutiveCopiesOnTop_whenPoppingConsecutiveRoutes_thenSingleNewCopyRemains() { + val result = reduceBackStack( + backStack = listOf( + route("home"), + route("details", "first"), + route("details", "second"), + route("details", "third"), + ), + command = WireNavigationCommand( + destination = route("details", "replacement"), + backStackMode = WireBackStackMode.POP_CONSECUTIVE_SAME_ROUTES, + ), + ) + + assertEquals(listOf(route("home"), route("details", "replacement")), result) + } + + @Test + fun givenSameRouteOnTop_whenLaunchingSingleTop_thenArgumentsAreUpdatedWithoutAddingEntry() { + val result = reduceBackStack( + backStack = listOf(route("home"), route("profile", "old")), + command = WireNavigationCommand(destination = route("profile", "new")), + ) + + assertEquals(listOf(route("home"), route("profile", "new")), result) + } + + private fun command(routeId: String, mode: WireBackStackMode) = + WireNavigationCommand(destination = route(routeId), backStackMode = mode) + + private fun route(routeId: String, argument: String = "", flowId: String? = null): TestRoute = + TestRoute(routeId = routeId, argument = argument, flowId = flowId) + + private data class TestRoute( + override val routeId: String, + val argument: String, + override val flowId: String?, + override val entryId: WireNavEntryId = WireNavEntryId("$routeId-$argument-${flowId.orEmpty()}"), + ) : WireRoute +} diff --git a/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireDeepLinkResolverTest.kt b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireDeepLinkResolverTest.kt new file mode 100644 index 00000000000..8a1c301c66f --- /dev/null +++ b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireDeepLinkResolverTest.kt @@ -0,0 +1,184 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class WireDeepLinkResolverTest { + + @Test + fun givenMultipleMatchingResolvers_whenResolving_thenFirstRegisteredResolverWins() { + val chain = WireDeepLinkResolverChain( + listOf( + resolverWith(WireDeepLinkResolution.Resolved(listOf(route("specific")))), + resolverWith(WireDeepLinkResolution.Resolved(listOf(route("broad")))), + ) + ) + + val result = chain.resolve(WireDeepLinkRequest("wire://conversation/id")) + + assertEquals( + WireDeepLinkResolution.Resolved(listOf(route("specific"))), + result, + ) + } + + @Test + fun givenEarlierResolverDoesNotMatch_whenResolving_thenNextResolverCanClaimLink() { + val chain = WireDeepLinkResolverChain( + listOf( + WireDeepLinkResolver { WireDeepLinkResolverResult.NotMatched }, + resolverWith(WireDeepLinkResolution.Resolved(listOf(route("fallback")))), + ) + ) + + val result = chain.resolve(WireDeepLinkRequest("wire://conversation/id")) + + assertEquals( + WireDeepLinkResolution.Resolved(listOf(route("fallback"))), + result, + ) + } + + @Test + fun givenRecognizedMalformedLink_whenResolving_thenInvalidResultStopsFallback() { + var fallbackInvocations = 0 + val chain = WireDeepLinkResolverChain( + listOf( + resolverWith( + WireDeepLinkResolution.Invalid( + WireDeepLinkInvalidReason.MISSING_REQUIRED_ARGUMENT + ) + ), + WireDeepLinkResolver { + fallbackInvocations++ + WireDeepLinkResolverResult.Matched( + WireDeepLinkResolution.Resolved(listOf(route("fallback"))) + ) + }, + ) + ) + + val result = chain.resolve(WireDeepLinkRequest("wire://conversation")) + + assertEquals( + WireDeepLinkResolution.Invalid(WireDeepLinkInvalidReason.MISSING_REQUIRED_ARGUMENT), + result, + ) + assertEquals(0, fallbackInvocations) + } + + @Test + fun givenBlankInput_whenResolving_thenInputIsInvalidAndResolversAreNotInvoked() { + var invocations = 0 + val chain = WireDeepLinkResolverChain( + listOf( + WireDeepLinkResolver { + invocations++ + WireDeepLinkResolverResult.NotMatched + } + ) + ) + + val result = chain.resolve(WireDeepLinkRequest(" ")) + + assertEquals( + WireDeepLinkResolution.Invalid(WireDeepLinkInvalidReason.EMPTY_INPUT), + result, + ) + assertEquals(0, invocations) + } + + @Test + fun givenNoResolverClaimsLink_whenResolving_thenResultIsExplicitlyUnsupported() { + val chain = WireDeepLinkResolverChain( + listOf(WireDeepLinkResolver { WireDeepLinkResolverResult.NotMatched }) + ) + + val result = chain.resolve(WireDeepLinkRequest("wire://unknown")) + + assertIs(result) + } + + @Test + fun givenKnownLinkWithoutSession_whenResolving_thenPendingRequestIsPreserved() { + val request = WireDeepLinkRequest("wire://conversation/id") + val chain = WireDeepLinkResolverChain( + listOf( + resolverWith(WireDeepLinkResolution.AuthenticationRequired(request)) + ) + ) + + val result = chain.resolve(request) + + assertEquals(WireDeepLinkResolution.AuthenticationRequired(request), result) + } + + @Test + fun givenKnownLinkAwaitingBusinessWork_whenResolving_thenDeferredReasonIsExplicit() { + val request = WireDeepLinkRequest("wire://conversation-join?code=x&key=y") + val chain = WireDeepLinkResolverChain( + listOf( + resolverWith( + WireDeepLinkResolution.Deferred( + pendingRequest = request, + reason = WireDeepLinkDeferredReason.BUSINESS_VALIDATION, + ) + ) + ) + ) + + val result = chain.resolve(request) + + assertEquals( + WireDeepLinkResolution.Deferred( + pendingRequest = request, + reason = WireDeepLinkDeferredReason.BUSINESS_VALIDATION, + ), + result, + ) + } + + @Test + fun givenResolvedStackWithDuplicateEntryIds_whenCreatingResolution_thenItIsRejected() { + assertFailsWith { + WireDeepLinkResolution.Resolved( + listOf(route("first", entryId = "duplicate"), route("second", entryId = "duplicate")) + ) + } + } + + private fun resolverWith(resolution: WireDeepLinkResolution) = + WireDeepLinkResolver { + WireDeepLinkResolverResult.Matched(resolution) + } + + private fun route( + routeId: String, + entryId: String = "$routeId-entry", + ) = TestRoute(routeId, WireNavEntryId(entryId)) + + private data class TestRoute( + override val routeId: String, + override val entryId: WireNavEntryId, + ) : WireRoute +} diff --git a/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavResultRegistryTest.kt b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavResultRegistryTest.kt new file mode 100644 index 00000000000..aeb76870bbd --- /dev/null +++ b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavResultRegistryTest.kt @@ -0,0 +1,294 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WireNavResultRegistryTest { + private val stringContract = WireNavResultContract(WireNavResultContractId("string")) + private val nullableStringContract = + WireNavResultContract(WireNavResultContractId("nullable-string")) + + @Test + fun givenTwoInstancesOfSameRoute_whenResultCompletes_thenOnlyRequestingEntryCanConsumeIt() { + val registry = WireNavResultRegistry(stringContract) + val request = request(requester = "same-route-entry-1", target = "same-route-entry-2") + registry.register(request) + registry.complete(request.id, WireNavResult.Value("result")) + + assertNull(registry.consume(request.id, entry("same-route-entry-2"))) + assertEquals( + WireNavResult.Value("result"), + registry.consume(request.id, entry("same-route-entry-1")), + ) + } + + @Test + fun givenNullableResult_whenCompletingWithNull_thenItIsDistinctFromCancellation() { + val registry = WireNavResultRegistry(nullableStringContract) + val request = request(contractId = nullableStringContract.id) + registry.register(request) + + assertTrue(registry.complete(request.id, WireNavResult.Value(null))) + + val result = registry.consume(request.id, request.requesterEntryId) + assertIs>(result) + assertNull(result.value) + } + + @Test + fun givenPendingRequest_whenTwoProducersComplete_thenOnlyFirstCompletionWins() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + + assertTrue(registry.complete(request.id, WireNavResult.Value("first"))) + assertFalse(registry.complete(request.id, WireNavResult.Value("second"))) + assertEquals( + WireNavResult.Value("first"), + registry.consume(request.id, request.requesterEntryId), + ) + } + + @Test + fun givenCompletedRequest_whenConsumedTwice_thenSecondConsumptionReturnsNothing() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + registry.complete(request.id, WireNavResult.Value("once")) + + assertEquals( + WireNavResult.Value("once"), + registry.consume(request.id, request.requesterEntryId), + ) + assertNull(registry.consume(request.id, request.requesterEntryId)) + } + + @Test + fun givenPendingRequest_whenTargetNavigationIsRejected_thenRequestCanBeRolledBack() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + + assertTrue(registry.discardPending(request.id)) + assertFalse(registry.discardPending(request.id)) + assertTrue(registry.snapshot().records.isEmpty()) + } + + @Test + fun givenPendingRequest_whenTargetIsPoppedNormally_thenRequesterReceivesCancellation() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + + registry.onEntryRemoved( + entryId = request.targetEntryId, + remainingEntryIds = setOf(request.requesterEntryId), + ) + + assertEquals( + WireNavResult.Canceled, + registry.consume(request.id, request.requesterEntryId), + ) + } + + @Test + fun givenCompletedRequest_whenTargetIsPopped_thenExistingValueIsPreserved() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + registry.complete(request.id, WireNavResult.Value("saved")) + + registry.onEntryRemoved( + entryId = request.targetEntryId, + remainingEntryIds = setOf(request.requesterEntryId), + ) + + assertEquals( + WireNavResult.Value("saved"), + registry.consume(request.id, request.requesterEntryId), + ) + } + + @Test + fun givenPendingRequest_whenRequesterIsPopped_thenRequestIsDiscarded() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + + registry.onEntryRemoved( + entryId = request.requesterEntryId, + remainingEntryIds = setOf(request.targetEntryId), + ) + + assertTrue(registry.snapshot().records.isEmpty()) + } + + @Test + fun givenRequests_whenStackIsReplaced_thenRelationshipsOutsideNewStackArePruned() { + val registry = WireNavResultRegistry(stringContract) + val retained = request(id = "retained", requester = "a", target = "b") + val staleRequester = request(id = "stale-requester", requester = "old", target = "b") + val staleTarget = request(id = "stale-target", requester = "a", target = "old") + listOf(retained, staleRequester, staleTarget).forEach(registry::register) + + registry.prune(setOf(entry("a"), entry("b"))) + + assertEquals(listOf(retained.id), registry.snapshot().records.map { it.request.id }) + } + + @Test + fun givenRequests_whenNavigationSessionChanges_thenAllRequestsAreCleared() { + val registry = WireNavResultRegistry(stringContract) + registry.register(request()) + + registry.clear() + + assertTrue(registry.snapshot().records.isEmpty()) + } + + @Test + fun givenPendingAndCompletedRequests_whenSnapshotIsSerialized_thenStatesRestoreExactly() { + val pending = request(id = "pending", requester = "a", target = "b") + val completed = request(id = "completed", requester = "a", target = "c") + val registry = WireNavResultRegistry(stringContract) + registry.register(pending) + registry.register(completed) + registry.complete(completed.id, WireNavResult.Value("value")) + + val encoded = Json.encodeToString(registry.snapshot()) + val restoredSnapshot = + Json.decodeFromString>(encoded) + val restored = WireNavResultRegistry(stringContract, restoredSnapshot) + + assertIs(restored.snapshot().records[0].state) + assertEquals( + WireNavResult.Value("value"), + restored.consume(completed.id, completed.requesterEntryId), + ) + } + + @Test + fun givenDuplicateRequestId_whenRegisteringAgain_thenSecondRegistrationIsRejected() { + val registry = WireNavResultRegistry(stringContract) + val first = request(id = "same", requester = "a", target = "b") + val second = request(id = "same", requester = "c", target = "d") + + assertTrue(registry.register(first)) + assertFalse(registry.register(second)) + assertEquals(listOf(first), registry.snapshot().records.map { it.request }) + } + + @Test + fun givenOnePendingRequestForTarget_whenResolvingByTarget_thenItsIdIsReturned() { + val registry = WireNavResultRegistry(stringContract) + val request = request(id = "unique") + registry.register(request) + + assertEquals( + request.id, + registry.uniquePendingRequestForTarget(request.targetEntryId), + ) + } + + @Test + fun givenMultiplePendingRequestsForTarget_whenResolvingByTarget_thenNothingIsReturned() { + val registry = WireNavResultRegistry(stringContract) + registry.register(request(id = "first", requester = "first-requester")) + registry.register(request(id = "second", requester = "second-requester")) + + assertNull(registry.uniquePendingRequestForTarget(entry("target-entry"))) + } + + @Test + fun givenDifferentTypedContracts_whenRequestsCoexist_thenEachRegistryOwnsOnlyItsContract() { + val integerContract = WireNavResultContract(WireNavResultContractId("integer")) + val strings = WireNavResultRegistry(stringContract) + val integers = WireNavResultRegistry(integerContract) + val stringRequest = request(id = "string-request") + val integerRequest = request(id = "integer-request", contractId = integerContract.id) + + assertTrue(strings.register(stringRequest)) + assertTrue(integers.register(integerRequest)) + assertTrue(strings.complete(stringRequest.id, WireNavResult.Value("typed"))) + assertTrue(integers.complete(integerRequest.id, WireNavResult.Value(42))) + + assertEquals( + WireNavResult.Value("typed"), + strings.consume(stringRequest.id, stringRequest.requesterEntryId), + ) + assertEquals( + WireNavResult.Value(42), + integers.consume(integerRequest.id, integerRequest.requesterEntryId), + ) + } + + @Test + fun givenPendingRequest_whenCompletingAndPopping_thenBothMutationsUseOneCoordinatorOperation() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + val removedEntries = mutableListOf() + val coordinator = WireNavResultCoordinator(registry) { + removedEntries += it + true + } + + assertTrue(coordinator.completeAndPop(request.id, WireNavResult.Value("done"))) + assertFalse(coordinator.completeAndPop(request.id, WireNavResult.Value("duplicate"))) + + assertEquals(listOf(request.targetEntryId), removedEntries) + assertEquals( + WireNavResult.Value("done"), + registry.consume(request.id, request.requesterEntryId), + ) + } + + @Test + fun givenTargetCannotBePopped_whenCompletingAndPopping_thenRequestRemainsPending() { + val registry = WireNavResultRegistry(stringContract) + val request = request() + registry.register(request) + val coordinator = WireNavResultCoordinator(registry, removeTarget = { false }) + + assertFalse(coordinator.completeAndPop(request.id, WireNavResult.Value("not-delivered"))) + + assertIs(registry.snapshot().records.single().state) + } + + private fun request( + id: String = "request", + requester: String = "requester-entry", + target: String = "target-entry", + contractId: WireNavResultContractId = stringContract.id, + ) = WireNavResultRequest( + id = WireNavResultRequestId(id), + contractId = contractId, + requesterEntryId = entry(requester), + targetEntryId = entry(target), + ) + + private fun entry(value: String) = WireNavEntryId(value) +} diff --git a/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavigationControllerTest.kt b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavigationControllerTest.kt new file mode 100644 index 00000000000..a4f1a513797 --- /dev/null +++ b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireNavigationControllerTest.kt @@ -0,0 +1,111 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import androidx.navigation3.runtime.NavKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class WireNavigationControllerTest { + + @Test + fun givenAcceptedCommand_whenNavigating_thenBackStackIsUpdated() { + val backStack = mutableListOf(route("home")) + val controller = WireNavigationController(backStack) + + val accepted = controller.navigate(WireNavigationCommand(route("settings"))) + + assertTrue(accepted) + assertEquals(listOf(route("home"), route("settings")), controller.routes) + } + + @Test + fun givenRejectedCommand_whenNavigating_thenBackStackIsUnchanged() { + val backStack = mutableListOf(route("home")) + val controller = WireNavigationController(backStack, canNavigate = { false }) + + val accepted = controller.navigate(WireNavigationCommand(route("settings"))) + + assertFalse(accepted) + assertEquals(listOf(route("home")), controller.routes) + } + + @Test + fun givenOnlyStartRoute_whenGoingBack_thenStartRouteIsRetained() { + val controller = WireNavigationController(mutableListOf(route("home"))) + + assertFalse(controller.goBack()) + assertEquals(listOf(route("home")), controller.routes) + } + + @Test + fun givenResolvedDeepLinkStack_whenReplacingBackStack_thenWholeStackIsReplaced() { + val controller = WireNavigationController(mutableListOf(route("home"), route("settings"))) + + controller.replaceBackStack(listOf(route("home"), route("conversation"))) + + assertEquals(listOf(route("home"), route("conversation")), controller.routes) + } + + @Test + fun givenEmptyStack_whenReplacingBackStack_thenOperationIsRejected() { + val controller = WireNavigationController(mutableListOf(route("home"))) + + assertFailsWith { + controller.replaceBackStack(emptyList()) + } + } + + @Test + fun givenExistingEntryId_whenNavigatingToSameEntry_thenOperationIsRejected() { + val existingRoute = route("home") + val controller = WireNavigationController(mutableListOf(existingRoute)) + + assertFailsWith { + controller.navigate(WireNavigationCommand(existingRoute, launchSingleTop = false)) + } + assertEquals(listOf(existingRoute), controller.routes) + } + + @Test + fun givenDuplicateEntryIds_whenReplacingBackStack_thenOperationIsRejected() { + val controller = WireNavigationController(mutableListOf(route("home"))) + val duplicatedEntryId = WireNavEntryId("duplicate-entry") + + assertFailsWith { + controller.replaceBackStack( + listOf( + TestRoute("home", duplicatedEntryId), + TestRoute("settings", duplicatedEntryId), + ) + ) + } + assertEquals(listOf(route("home")), controller.routes) + } + + private fun route(id: String) = TestRoute(id) + + private data class TestRoute( + override val routeId: String, + override val entryId: WireNavEntryId = WireNavEntryId("$routeId-entry"), + ) : WireRoute +} diff --git a/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireViewModelOwnerTest.kt b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireViewModelOwnerTest.kt new file mode 100644 index 00000000000..e8580031a28 --- /dev/null +++ b/core/navigation-kmp/src/commonTest/kotlin/com/wire/navigation/WireViewModelOwnerTest.kt @@ -0,0 +1,181 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlinx.serialization.json.Json + +class WireViewModelOwnerTest { + + @Test + fun givenEveryOwnerType_whenSerializedAndRestored_thenValueIsPreserved() { + val owners = listOf( + WireViewModelOwner.Entry(WireNavEntryId("entry-id")), + WireViewModelOwner.Flow("authentication"), + WireViewModelOwner.Session(WireSessionId("user-id", "wire.example")), + WireViewModelOwner.Application, + ) + + owners.forEach { owner -> + val serialized = Json.encodeToString(WireViewModelOwner.serializer(), owner) + + assertEquals( + owner, + Json.decodeFromString(WireViewModelOwner.serializer(), serialized), + ) + } + } + + @Test + fun givenOwnerValues_whenCreatingStableKeys_thenKeysContainTypedIdentity() { + assertEquals( + "entry:entry-id", + WireViewModelOwner.Entry(WireNavEntryId("entry-id")).stableKey(), + ) + assertEquals( + "flow:create-conversation", + WireViewModelOwner.Flow("create-conversation").stableKey(), + ) + assertEquals( + "session:user-id@wire.example", + WireViewModelOwner.Session(WireSessionId("user-id", "wire.example")).stableKey(), + ) + assertEquals("application", WireViewModelOwner.Application.stableKey()) + } + + @Test + fun givenDifferentSessionDomains_whenCreatingStableKeys_thenKeysDoNotCollide() { + val first = WireViewModelOwner.Session(WireSessionId("user-id", "wire.example")) + val second = WireViewModelOwner.Session(WireSessionId("user-id", "other.example")) + + assertNotEquals(first.stableKey(), second.stableKey()) + } + + @Test + fun givenBlankFlowOwnerId_whenCreatingOwner_thenCreationFails() { + assertFailsWith { + WireViewModelOwner.Flow(" ") + } + } + + @Test + fun givenOrdinaryRoute_whenResolvingEntryOwner_thenConcreteEntryOwnsViewModel() { + val route = TestRoute(entryId = WireNavEntryId("ordinary")) + + assertEquals( + WireViewModelOwner.Entry(route.entryId), + route.entryViewModelOwner(), + ) + } + + @Test + fun givenRouteWithFlowId_whenResolvingEntryOwner_thenConcreteEntryStillOwnsViewModel() { + val route = TestRoute( + entryId = WireNavEntryId("login-email"), + flowId = "authentication", + ) + + assertEquals( + WireViewModelOwner.Entry(route.entryId), + route.entryViewModelOwner(), + ) + } + + @Test + fun givenSessionRoute_whenResolvingEntryOwner_thenConcreteEntryStillOwnsViewModel() { + val route = TestSessionRoute(entryId = WireNavEntryId("session-screen")) + + assertEquals( + WireViewModelOwner.Entry(route.entryId), + route.entryViewModelOwner(), + ) + } + + @Test + fun givenOrdinaryRoute_whenListingSharedOwners_thenOnlyApplicationOwnerIsAvailable() { + val route = TestRoute(entryId = WireNavEntryId("ordinary")) + + assertEquals( + setOf(WireViewModelOwner.Application), + route.availableSharedViewModelOwners(), + ) + } + + @Test + fun givenRouteWithFlowId_whenListingSharedOwners_thenFlowAndApplicationOwnersAreAvailable() { + val route = TestRoute( + entryId = WireNavEntryId("login-email"), + flowId = "authentication", + ) + + assertEquals( + setOf( + WireViewModelOwner.Flow("authentication"), + WireViewModelOwner.Application, + ), + route.availableSharedViewModelOwners(), + ) + } + + @Test + fun givenSessionRoute_whenListingSharedOwners_thenSessionAndApplicationOwnersAreAvailable() { + val route = TestSessionRoute(entryId = WireNavEntryId("session-screen")) + + assertEquals( + setOf( + WireViewModelOwner.Session(route.sessionId), + WireViewModelOwner.Application, + ), + route.availableSharedViewModelOwners(), + ) + } + + @Test + fun givenSessionRouteWithFlowId_whenListingSharedOwners_thenAllExplicitSharedOwnersAreAvailable() { + val route = TestSessionRoute( + entryId = WireNavEntryId("session-flow"), + flowId = "authentication", + ) + + assertEquals( + setOf( + WireViewModelOwner.Flow("authentication"), + WireViewModelOwner.Session(route.sessionId), + WireViewModelOwner.Application, + ), + route.availableSharedViewModelOwners(), + ) + } + + private data class TestRoute( + override val entryId: WireNavEntryId, + override val routeId: String = "test", + override val flowId: String? = null, + ) : WireRoute + + private data class TestSessionRoute( + override val entryId: WireNavEntryId, + override val routeId: String = "test-session", + override val flowId: String? = null, + override val sessionId: WireSessionId = WireSessionId("user-id", "wire.example"), + ) : SessionRoute +} diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 9b9b54bf937..dd801c5b007 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id(libs.plugins.wire.android.library.get().pluginId) id(libs.plugins.wire.kover.get().pluginId) id(libs.plugins.wire.compose.compiler.get().pluginId) + id(BuildPlugins.junit5) alias(libs.plugins.compose.stability.analyzer) } @@ -10,9 +11,16 @@ android { } dependencies { + api(projects.core.navigationKmp) implementation(projects.core.uiCommon) implementation(libs.visibilityModifiers) - implementation(libs.compose.navigation) - implementation(libs.compose.destinations.core) - implementation(libs.compose.material3) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.compose.multiplatform.navigation3.ui) + implementation(libs.androidx.lifecycle.viewModelNavigation3) + + testImplementation(libs.junit4) + testImplementation(libs.junit5.core) + testImplementation(libs.robolectric) + testRuntimeOnly(libs.junit5.engine) + testRuntimeOnly(libs.junit5.vintage.engine) } diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/NavigationCommand.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/NavigationCommand.kt deleted file mode 100644 index 247f7e24199..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/NavigationCommand.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import com.ramcosta.composedestinations.spec.Direction - -/** - * Wrapper class used to specify to the Navigator the new component we want to navigate to. - */ -data class NavigationCommand( - /** - * The destination route of the component we want to navigate to. - */ - val destination: Direction, - - /** - * Whether we want to clear the previously added screens on the backstack, only until the current one, or none of them. - */ - val backStackMode: BackStackMode = BackStackMode.NONE, - - /** - * Whether we want to clear the backstack of the current graph. - */ - val launchSingleTop: Boolean = true -) - -enum class BackStackMode { - // clear the whole backstack excluding "start screen" - CLEAR_TILL_START, - - // clear the whole backstack including "start screen" (use when you navigate to a new "start screen" ) - CLEAR_WHOLE, - - // remove the current item from backstack before adding the new one. - // use it instead of: - // navigator.navigateBack() - // navigator.navigate(SomeWhere) - REMOVE_CURRENT, - - // Remove all items from currently entered nested graph before adding the new one. - // For instance with screens A, B, C and D which is a nested graph containing screens D1, D2, D3, - // if the current stack is A -> B -> D1 -> D2 -> D3 and we want to complete D flow and navigate to C, - // then we add this flag and after navigation the stack will be A -> B -> C. - REMOVE_CURRENT_NESTED_GRAPH, - - // if there is an instance of that screen in backStack, - // then app pops stack till that screen and replace it by the new screen. - // if no instance in backStack, then just add screen in top of stack. - UPDATE_EXISTED, - - // remove the current item from backstack and then pops stack till that screen and replace it by the new screen. - // it's REMOVE_CURRENT and UPDATE_EXISTED applied one after the other respectively - REMOVE_CURRENT_AND_REPLACE, - - // Pops all consecutive instances of the same screen (based on route) from the backstack, - // including the current one. Useful when the same destination has been pushed multiple times - // (e.g., Details -> Details -> Details), and you want to clean up duplicates by going back to the last different screen. - POP_CONSECUTIVE_SAME_SCREENS, - - // screen will be added to the existing backstack. - NONE -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewNavigator.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewNavigator.kt deleted file mode 100644 index 91c0acbb84b..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewNavigator.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -object PreviewNavigator : WireNavigator { - override fun navigate(navigationCommand: NavigationCommand) { /* No-op */ } - override fun navigateBack() { /* No-op */ } - override fun navigateBackAndRemoveAllConsecutive(currentRoute: String) { /* No-op */ } - override fun navigateBackAndRemoveAllConsecutiveXTimes(currentRoute: String, stepsBack: Int) { /* No-op */ } -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultBackNavigator.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultBackNavigator.kt deleted file mode 100644 index 451f8c9c0eb..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultBackNavigator.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import com.ramcosta.composedestinations.result.ResultBackNavigator - -object PreviewResultBackNavigator : ResultBackNavigator { - override fun setResult(result: R) { /* No-op */ } - - override fun navigateBack(result: R) { /* No-op */ } - - override fun navigateBack() { /* No-op */ } -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultRecipient.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultRecipient.kt deleted file mode 100644 index d7b9b927943..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/PreviewResultRecipient.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisallowComposableCalls -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.OpenResultRecipient -import com.ramcosta.composedestinations.result.ResultRecipient -import com.ramcosta.composedestinations.spec.TypedDestinationSpec - -@SuppressLint("ComposeNamingUppercase") -object PreviewResultRecipient : ResultRecipient, String> { - - @Composable - override fun onNavResult( - listener: @DisallowComposableCalls (NavResult) -> Unit - ) { - // fake result - } - - @Composable - override fun onNavResult( - deliverResultOn: OpenResultRecipient.DeliverResultOn, - listener: @DisallowComposableCalls (NavResult) -> Unit - ) { - // fake result - } -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/WireNavigator.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/WireNavigator.kt deleted file mode 100644 index b3d530de4b5..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/WireNavigator.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation - -import androidx.compose.runtime.Stable - -@Stable -interface WireNavigator { - fun navigate(navigationCommand: NavigationCommand) - fun navigateBack() - fun navigateBackAndRemoveAllConsecutive(currentRoute: String) - fun navigateBackAndRemoveAllConsecutiveXTimes(currentRoute: String, stepsBack: Int) -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentation.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentation.kt new file mode 100644 index 00000000000..daaa71a4bd0 --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentation.kt @@ -0,0 +1,162 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.togetherWith +import androidx.compose.ui.window.DialogProperties +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.scene.Scene +import androidx.navigation3.ui.NavDisplay +import com.wire.android.navigation.style.TransitionAnimationType + +/** + * Describes how a Navigation 3 entry is presented. + * + * The underlying Navigation 3 metadata is deliberately kept private. This gives feature entry + * providers a stable Wire-owned API while allowing this type to grow with transition and scene + * metadata without leaking framework-specific keys into every feature. + */ +class WireEntryPresentation private constructor( + @PublishedApi + internal val navEntryMetadata: Map, + internal val transitionProfile: WireEntryTransitionProfile, + @PublishedApi + internal val interactionPolicy: WireTransitionInteractionPolicy, +) { + + companion object { + /** + * Presents an entry with Wire's standard horizontal transition. + * + * This is deliberately an alias of [Slide], rather than Navigation 3's framework default, + * so entries migrated without an explicit profile retain the current application motion. + */ + val Default: WireEntryPresentation + get() = Slide + + /** + * The new entry slides in from the right; on back it slides out to the right. + */ + val Slide: WireEntryPresentation = animated( + profile = WireEntryTransitionProfile.Slide, + animationType = TransitionAnimationType.SLIDE, + ) + + /** + * The new entry rises from the bottom; on back it returns towards the bottom. + */ + val PopUp: WireEntryPresentation = animated( + profile = WireEntryTransitionProfile.PopUp, + animationType = TransitionAnimationType.POP_UP, + ) + + /** + * Disables forward, back and predictive-back content animation for this entry. + */ + val None: WireEntryPresentation = animated( + profile = WireEntryTransitionProfile.None, + animationType = TransitionAnimationType.NONE, + ) + + /** + * Presents an entry in a platform dialog over the current scene. + * + * This is an explicit platform-dialog policy only. Responsive tablet promotion remains a + * separate scene-selection concern and must not be inferred from this factory. + */ + fun dialog( + properties: DialogProperties = DialogProperties(), + ): WireEntryPresentation = WireEntryPresentation( + navEntryMetadata = DialogSceneStrategy.dialog(properties), + transitionProfile = WireEntryTransitionProfile.Dialog, + interactionPolicy = WireTransitionInteractionPolicy.BlockUntilSettled, + ) + + private fun animated( + profile: WireEntryTransitionProfile, + animationType: TransitionAnimationType, + ): WireEntryPresentation = WireEntryPresentation( + navEntryMetadata = transitionMetadata(animationType), + transitionProfile = profile, + interactionPolicy = WireTransitionInteractionPolicy.BlockUntilSettled, + ) + } +} + +/** + * Wire-owned transition names used by tests and migration tooling. + * + * Feature entry providers choose [WireEntryPresentation] values and never depend on Navigation 3 + * metadata keys or animation scopes directly. + */ +internal enum class WireEntryTransitionProfile { + Slide, + PopUp, + None, + Dialog, +} + +/** + * Whether an entry accepts pointer input while its containing scene is changing. + */ +@PublishedApi +internal enum class WireTransitionInteractionPolicy { + AllowDuringTransition, + BlockUntilSettled; + + fun shouldBlockInput( + isTransitionRunning: Boolean, + isEntryResumed: Boolean, + ): Boolean = this == BlockUntilSettled && isTransitionRunning && !isEntryResumed +} + +private fun transitionMetadata( + animationType: TransitionAnimationType, +): Map = + NavDisplay.transitionSpec { + animationType.forwardContentTransform() + } + NavDisplay.popTransitionSpec { + animationType.popContentTransform() + } + NavDisplay.predictivePopTransitionSpec { + // The same seekable transform keeps regular and gesture-driven back visually coherent. + // A future RTL-aware profile can use the swipe-edge argument without changing features. + animationType.popContentTransform() + } + +internal fun TransitionAnimationType.forwardContentTransform(): ContentTransform = + enterTransition.togetherWith(exitTransition) + +internal fun TransitionAnimationType.popContentTransform(): ContentTransform = + popEnterTransition.togetherWith(popExitTransition) + +internal fun wireDefaultTransitionSpec(): AnimatedContentTransitionScope>.() -> ContentTransform = { + TransitionAnimationType.SLIDE.forwardContentTransform() +} + +internal fun wireDefaultPopTransitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + TransitionAnimationType.SLIDE.popContentTransform() +} + +internal fun wireDefaultPredictivePopTransitionSpec(): + AnimatedContentTransitionScope>.(Int) -> ContentTransform = { + TransitionAnimationType.SLIDE.popContentTransform() +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryProvider.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryProvider.kt new file mode 100644 index 00000000000..54949f4415a --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireEntryProvider.kt @@ -0,0 +1,83 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import com.wire.navigation.WireRoute +import com.wire.navigation.availableSharedViewModelOwners +import com.wire.navigation.entryViewModelOwner + +/** + * A feature-owned contribution to the application's Navigation 3 entry provider. + */ +typealias WireEntryProviderInstaller = EntryProviderScope.() -> Unit + +/** + * Supplies route-specific runtime dependencies without coupling feature entry providers to the + * application module. The app implementation selects the Metro graph from the typed [route]. + */ +fun interface WireEntryEnvironment { + @Composable + fun Provide( + route: WireRoute, + content: @Composable () -> Unit, + ) +} + +private val PassThroughWireEntryEnvironment = WireEntryEnvironment { _, content -> content() } + +val LocalWireEntryEnvironment = staticCompositionLocalOf { + PassThroughWireEntryEnvironment +} + +/** + * Registers a Wire route and always applies its route-specific environment. + * + * Feature modules should use this instead of Navigation 3's raw `entry` function. + */ +inline fun EntryProviderScope.wireEntry( + presentation: WireEntryPresentation = WireEntryPresentation.Default, + metadata: Map = emptyMap(), + noinline content: @Composable (Route) -> Unit, +) { + entry( + clazzContentKey = { route -> route.entryId.value }, + metadata = { route -> + metadata + + presentation.navEntryMetadata + + WireViewModelStoreNavEntryDecorator.owners( + entryOwner = route.entryViewModelOwner(), + sharedOwners = route.availableSharedViewModelOwners(), + ) + } + ) { route -> + LocalWireEntryEnvironment.current.Provide(route) { + WireTransitionInteractionGate(presentation.interactionPolicy) { + content(route) + } + } + } +} + +internal fun unknownWireEntry(key: NavKey): NavEntry = + error("No Navigation 3 entry registered for ${key::class.qualifiedName}") diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNav3Host.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNav3Host.kt new file mode 100644 index 00000000000..da070a1d07b --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNav3Host.kt @@ -0,0 +1,146 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay +import com.wire.android.navigation.transition.LocalSharedTransitionScope +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.isTablet as isTabletLayout +import com.wire.navigation.WireRoute +import com.wire.navigation.WireViewModelOwner + +/** + * Production Navigation 3 host used after the root-host switch. + * + * Entry lifecycle, saveable state and ViewModel stores are owned by Navigation 3 entries. The + * parent owner propagates its default factory and CreationExtras to those entries, which is + * required for Metro factories and SavedStateHandle creation. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun WireNav3Host( + runtime: WireNavigation3Runtime, + entryEnvironment: WireEntryEnvironment, + entryProviderInstallers: List, + modifier: Modifier = Modifier, + onBack: () -> Unit = { runtime.navigator.goBack() }, + responsivePresentationPolicy: WireResponsivePresentationPolicy = + WireResponsivePresentationPolicy.None, + isTablet: Boolean = isTabletLayout, + parentViewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No parent ViewModelStoreOwner was provided to WireNav3Host" + }, + sharedViewModelStoreProvider: ViewModelStoreProvider = + rememberWireViewModelStoreProvider(parentViewModelStoreOwner), + onViewModelOwnerAvailable: (WireViewModelOwner, ViewModelStoreOwner) -> Unit = { _, _ -> }, + onViewModelOwnerReleased: (WireViewModelOwner, ViewModelStoreOwner) -> Unit = { _, _ -> }, + onViewModelOwnerCleared: (String) -> Unit = {}, +) { + val registeredEntries = entryProvider( + fallback = ::unknownWireEntry, + ) { + entryProviderInstallers.forEach { install -> install() } + } + val entries = responsiveEntryProvider( + delegate = registeredEntries, + policy = responsivePresentationPolicy, + isTablet = isTablet, + ) + + SharedTransitionLayout(modifier = modifier) { + CompositionLocalProvider( + LocalSharedTransitionScope provides this, + LocalWireEntryEnvironment provides entryEnvironment, + ) { + NavDisplay( + backStack = runtime.backStack, + onBack = onBack, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(parentViewModelStoreOwner), + rememberWireViewModelStoreNavEntryDecorator( + storeProvider = sharedViewModelStoreProvider, + onOwnerAvailable = onViewModelOwnerAvailable, + onOwnerReleased = onViewModelOwnerReleased, + onOwnerCleared = onViewModelOwnerCleared, + ), + ), + sceneStrategies = listOf(DialogSceneStrategy()), + transitionSpec = wireDefaultTransitionSpec(), + popTransitionSpec = wireDefaultPopTransitionSpec(), + predictivePopTransitionSpec = wireDefaultPredictivePopTransitionSpec(), + entryProvider = entries, + ) + } + } +} + +/** + * Adds responsive scene metadata after route registration. + * + * Responsive metadata is placed before entry-owned metadata so an explicit dialog presentation + * always wins, including its custom [androidx.compose.ui.window.DialogProperties]. Returning the + * original entry for phones and unmatched routes keeps their identity and metadata untouched. + */ +internal fun responsiveEntryProvider( + delegate: (NavKey) -> NavEntry, + policy: WireResponsivePresentationPolicy, + isTablet: Boolean, +): (NavKey) -> NavEntry = { key -> + val entry = delegate(key) + val route = key as? WireRoute + if (route == null || !policy.shouldPresentAsTabletDialog(route, isTablet)) { + entry + } else { + val responsiveMetadata = policy + .resolve(route = route, isTablet = true) + .navEntryMetadata + NavEntry( + key = key, + contentKey = entry.contentKey, + metadata = responsiveMetadata + entry.metadata, + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(dimensions().spacing20x)) + .imePadding() + ) { + entry.Content() + } + } + } +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Results.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Results.kt new file mode 100644 index 00000000000..5dbc7e6bf9b --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Results.kt @@ -0,0 +1,156 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultCoordinator +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultRegistry +import com.wire.navigation.WireNavResultRegistrySnapshot +import com.wire.navigation.WireNavResultRequest +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.decodeWireNavResultRegistrySnapshot +import com.wire.navigation.encode +import kotlinx.serialization.KSerializer + +/** + * Android persistence information for one KMP result contract. + * + * The serializer stays at the runtime boundary; only the contract id and encoded registry + * snapshot enter saved instance state. + */ +class WireNavigation3ResultType( + val contract: WireNavResultContract, + internal val serializer: KSerializer, +) + +internal class WireNavigation3ResultStore( + resultTypes: List>, + restored: Map = emptyMap(), +) { + private val channels = resultTypes.associate { type -> + type.contract.id.value to type.toChannel(restored[type.contract.id.value]) + } + + init { + require(channels.size == resultTypes.size) { + "Navigation result contract ids must be unique" + } + } + + fun register( + type: WireNavigation3ResultType, + request: WireNavResultRequest, + ): Boolean = channel(type).registry.register(request) + + fun discardPending( + type: WireNavigation3ResultType, + requestId: WireNavResultRequestId, + ): Boolean = channel(type).registry.discardPending(requestId) + + fun uniquePendingRequestForTarget( + type: WireNavigation3ResultType, + targetEntryId: WireNavEntryId, + ): WireNavResultRequestId? = + channel(type).registry.uniquePendingRequestForTarget(targetEntryId) + + fun consume( + type: WireNavigation3ResultType, + requestId: WireNavResultRequestId, + requesterEntryId: WireNavEntryId, + ): WireNavResult? = channel(type).registry.consume(requestId, requesterEntryId) + + fun completeAndPop( + type: WireNavigation3ResultType, + requestId: WireNavResultRequestId, + result: WireNavResult, + removeTarget: (WireNavEntryId) -> Boolean, + ): WireNavigation3Completion { + val registry = channel(type).registry + val targetEntryId = registry.snapshot().records + .firstOrNull { it.request.id == requestId } + ?.request + ?.targetEntryId + val completed = WireNavResultCoordinator(registry, removeTarget) + .completeAndPop(requestId, result) + return WireNavigation3Completion(completed, targetEntryId) + } + + fun onEntryRemoved(entryId: WireNavEntryId, remainingEntryIds: Set) { + channels.values.forEach { it.onEntryRemoved(entryId, remainingEntryIds) } + } + + fun prune(activeEntryIds: Set) { + channels.values.forEach { it.prune(activeEntryIds) } + } + + fun encode(): Map = channels.mapValues { (_, channel) -> channel.encode() } + + @Suppress("UNCHECKED_CAST") + private fun channel(type: WireNavigation3ResultType): TypedChannel { + val channel = channels[type.contract.id.value] + ?: error("Navigation result contract '${type.contract.id.value}' is not registered") + require(channel.type === type) { + "Use the result type instance registered in rememberWireNavigation3Runtime" + } + return channel as TypedChannel + } + + private interface Channel { + val type: WireNavigation3ResultType<*> + val contract: WireNavResultContract<*> + fun onEntryRemoved(entryId: WireNavEntryId, remainingEntryIds: Set) + fun prune(activeEntryIds: Set) + fun encode(): String + } + + private class TypedChannel( + override val type: WireNavigation3ResultType, + override val contract: WireNavResultContract, + private val serializer: KSerializer, + restored: WireNavResultRegistrySnapshot, + ) : Channel { + val registry = WireNavResultRegistry(contract, restored) + + override fun onEntryRemoved( + entryId: WireNavEntryId, + remainingEntryIds: Set, + ) = registry.onEntryRemoved(entryId, remainingEntryIds) + + override fun prune(activeEntryIds: Set) = registry.prune(activeEntryIds) + + override fun encode(): String = registry.snapshot().encode(serializer) + } + + private fun WireNavigation3ResultType.toChannel( + encoded: String?, + ): Channel = TypedChannel( + type = this, + contract = contract, + serializer = serializer, + restored = encoded?.let { decodeWireNavResultRegistrySnapshot(it, serializer) } + ?: WireNavResultRegistrySnapshot(), + ) +} + +internal data class WireNavigation3Completion( + val completed: Boolean, + val targetEntryId: WireNavEntryId?, +) diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt new file mode 100644 index 00000000000..98042806b8f --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt @@ -0,0 +1,227 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import com.wire.navigation.WireBackStackChange +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequest +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireNavigationController +import com.wire.navigation.WireRoute +import java.util.UUID + +/** + * Saveable Navigation 3 state together with Wire's only back-stack mutation API. + */ +@Stable +class WireNavigation3Runtime internal constructor( + val backStack: NavBackStack, + private val resultStore: WireNavigation3ResultStore, + canNavigate: (WireNavigationCommand) -> Boolean, + private val requestIdFactory: () -> WireNavResultRequestId, + private val onNavigationChanged: ( + previous: List, + current: List, + change: WireBackStackChange, + ) -> Unit = { _, _, _ -> }, +) { + val navigator = WireNavigationController( + backStack = backStack, + canNavigate = canNavigate, + onBackStackChanged = ::onBackStackChanged, + ) + + /** + * Pushes a fresh entry and binds its result to the concrete requester entry. + */ + fun navigateForResult( + destination: WireRoute, + resultType: WireNavigation3ResultType, + ): WireNavResultRequestId? { + val requesterEntryId = navigator.currentRoute?.entryId ?: return null + require(destination.entryId !in activeEntryIds()) { + "A route passed to navigateForResult must have a fresh entry id" + } + val requestId = requestIdFactory() + val request = WireNavResultRequest( + id = requestId, + contractId = resultType.contract.id, + requesterEntryId = requesterEntryId, + targetEntryId = destination.entryId, + ) + check(resultStore.register(resultType, request)) { + "The navigation result request id factory produced a duplicate id" + } + val accepted = runCatching { + navigator.navigate( + WireNavigationCommand(destination = destination, launchSingleTop = false) + ) + }.getOrElse { failure -> + check(resultStore.discardPending(resultType, requestId)) + throw failure + } + return if (accepted) { + requestId + } else { + check(resultStore.discardPending(resultType, requestId)) + null + } + } + + /** + * Completes a pending request and removes its current target as one serialized operation. + */ + fun completeAndPop( + requestId: WireNavResultRequestId, + resultType: WireNavigation3ResultType, + result: WireNavResult, + ): Boolean { + val completion = resultStore.completeAndPop( + type = resultType, + requestId = requestId, + result = result, + removeTarget = { targetEntryId -> + val current = navigator.currentRoute + if (current?.entryId != targetEntryId || backStack.size <= 1) { + false + } else { + backStack.removeLast() + true + } + }, + ) + if (completion.completed) { + val remaining = activeEntryIds() + // The completing channel already holds its terminal value; reconciliation preserves it + // while canceling any other pending request targeting the same removed entry. + completion.targetEntryId?.let { resultStore.onEntryRemoved(it, remaining) } + } + return completion.completed + } + + /** + * Completes the unique pending result request targeting the current entry and removes it. + * + * Request ids belong to the navigation relationship and therefore are not embedded in route + * arguments. Missing or ambiguous relationships are rejected without changing the stack. + */ + fun completeCurrentAndPop( + resultType: WireNavigation3ResultType, + result: WireNavResult, + ): Boolean { + val requestId = navigator.currentRoute?.entryId?.let { targetEntryId -> + resultStore.uniquePendingRequestForTarget( + type = resultType, + targetEntryId = targetEntryId, + ) + } + return requestId?.let { completeAndPop(it, resultType, result) } ?: false + } + + /** + * Consumes a result only when called by the exact entry that initiated the request. + */ + fun consumeResult( + requestId: WireNavResultRequestId, + resultType: WireNavigation3ResultType, + ): WireNavResult? { + val requesterEntryId = navigator.currentRoute?.entryId ?: return null + return resultStore.consume(resultType, requestId, requesterEntryId) + } + + private fun onBackStackChanged( + previous: List, + current: List, + change: WireBackStackChange, + ) { + onNavigationChanged(previous, current, change) + val active = current.mapTo(mutableSetOf()) { it.entryId } + if (change == WireBackStackChange.BACK) { + previous.asSequence() + .map { it.entryId } + .filterNot(active::contains) + .forEach { resultStore.onEntryRemoved(it, active) } + } else { + resultStore.prune(active) + } + } + + private fun activeEntryIds() = navigator.routes.mapTo(mutableSetOf()) { it.entryId } +} + +/** + * Creates navigation state that survives configuration changes and process recreation. + * + * Every concrete [WireRoute] placed on this stack must be annotated with + * `kotlinx.serialization.Serializable`. + */ +@Composable +fun rememberWireNavigation3Runtime( + startRoute: WireRoute, + resultTypes: List> = emptyList(), + canNavigate: (WireNavigationCommand) -> Boolean = { true }, + onNavigationChanged: ( + previous: List, + current: List, + change: WireBackStackChange, + ) -> Unit = { _, _, _ -> }, +): WireNavigation3Runtime { + val backStack = rememberNavBackStack(startRoute) + val currentCanNavigate by rememberUpdatedState(canNavigate) + val resultTypeIds = resultTypes.map { it.contract.id.value } + val resultStore = rememberSaveable( + resultTypeIds, + saver = listSaver( + save = { store -> + store.encode().flatMap { (contractId, snapshot) -> listOf(contractId, snapshot) } + }, + restore = { values -> + require(values.size % 2 == 0) { + "Saved navigation result state contains an incomplete channel" + } + val restored = values.chunked(2).associate { (contractId, snapshot) -> + contractId to snapshot + } + WireNavigation3ResultStore(resultTypes, restored) + }, + ), + ) { + WireNavigation3ResultStore(resultTypes) + } + return remember(backStack, resultStore) { + WireNavigation3Runtime( + backStack = backStack, + resultStore = resultStore, + canNavigate = { command -> currentCanNavigate(command) }, + requestIdFactory = { WireNavResultRequestId(UUID.randomUUID().toString()) }, + onNavigationChanged = onNavigationChanged, + ) + } +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicy.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicy.kt new file mode 100644 index 00000000000..f0708338dbe --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicy.kt @@ -0,0 +1,94 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.ui.window.DialogProperties +import com.wire.navigation.WireRoute +import kotlin.reflect.KClass + +/** + * The dialog configuration used by Wire's typed tablet presentation policy. + */ +val WireTabletDialogProperties: DialogProperties + get() = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = true, + decorFitsSystemWindows = false, + ) + +/** + * Selects responsive entry presentation using typed [WireRoute] instances. + * + * The policy deliberately contains no Android [android.content.res.Configuration] access. The + * composable host supplies the current layout mode, while Navigation 3 metadata callbacks only + * evaluate the stable route predicate captured here. + */ +class WireResponsivePresentationPolicy private constructor( + private val tabletDialogRoutePredicate: (WireRoute) -> Boolean, + internal val tabletDialogProperties: DialogProperties, +) { + + /** + * Resolves a route's presentation without changing explicit presentation on phones. + */ + fun resolve( + route: WireRoute, + isTablet: Boolean, + defaultPresentation: WireEntryPresentation = WireEntryPresentation.Default, + ): WireEntryPresentation = + if (shouldPresentAsTabletDialog(route, isTablet)) { + WireEntryPresentation.dialog(tabletDialogProperties) + } else { + defaultPresentation + } + + internal fun shouldPresentAsTabletDialog(route: WireRoute, isTablet: Boolean): Boolean = + isTablet && tabletDialogRoutePredicate(route) + + companion object { + /** A no-op policy for hosts that do not use responsive dialog presentation. */ + val None: WireResponsivePresentationPolicy = WireResponsivePresentationPolicy( + tabletDialogRoutePredicate = { false }, + tabletDialogProperties = WireTabletDialogProperties, + ) + + /** + * Creates a policy backed by concrete route types instead of generated base-route strings. + */ + fun tabletDialogsFor( + routeTypes: Set>, + properties: DialogProperties = WireTabletDialogProperties, + ): WireResponsivePresentationPolicy = WireResponsivePresentationPolicy( + tabletDialogRoutePredicate = { route -> route::class in routeTypes }, + tabletDialogProperties = properties, + ) + + /** + * Creates a policy for typed route families whose decision also depends on route data. + */ + fun tabletDialogsWhere( + properties: DialogProperties = WireTabletDialogProperties, + predicate: (WireRoute) -> Boolean, + ): WireResponsivePresentationPolicy = WireResponsivePresentationPolicy( + tabletDialogRoutePredicate = predicate, + tabletDialogProperties = properties, + ) + } +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireTransitionInteractionGate.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireTransitionInteractionGate.kt new file mode 100644 index 00000000000..f17b5627622 --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireTransitionInteractionGate.kt @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation3.ui.LocalNavAnimatedContentScope + +/** + * Prevents transition-time double taps from issuing a second navigation command. + * + * Navigation 3 normally keeps transitioning entries below `RESUMED`. Both signals are required: + * an entry lifecycle can lag behind a completed transition after session setup, while a nested + * scene can leave its animation scope running after the visible entry has resumed. Neither stale + * signal may permanently cover the screen with the input-consuming overlay. + */ +@OptIn(ExperimentalAnimationApi::class) +@Composable +@PublishedApi +internal fun WireTransitionInteractionGate( + policy: WireTransitionInteractionPolicy, + content: @Composable () -> Unit, +) { + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + val transitionRunning = LocalNavAnimatedContentScope.current.transition.isRunning + + Box(modifier = Modifier.fillMaxSize()) { + content() + if ( + policy.shouldBlockInput( + isTransitionRunning = transitionRunning, + isEntryResumed = lifecycleState == Lifecycle.State.RESUMED, + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { change -> change.consume() } + } + } + } + ) + } + } +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreNavEntryDecorator.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreNavEntryDecorator.kt new file mode 100644 index 00000000000..2befc84574d --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreNavEntryDecorator.kt @@ -0,0 +1,303 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.rememberViewModelStoreProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner +import com.wire.navigation.stableKey + +val LocalWireViewModelOwner = staticCompositionLocalOf { null } + +val LocalWireSharedViewModelStoreOwners = + staticCompositionLocalOf> { emptyMap() } + +/** + * Resolves one typed owner made available by the current route. + */ +@Composable +fun wireViewModelStoreOwner(owner: WireViewModelOwner): ViewModelStoreOwner = + when (owner) { + is WireViewModelOwner.Entry -> { + val currentOwner = checkNotNull(LocalWireViewModelOwner.current) { + "No Wire entry ViewModel owner is available" + } + require(currentOwner == owner) { + "Entry owner ${owner.stableKey()} does not belong to the current route" + } + checkNotNull(LocalViewModelStoreOwner.current) + } + + else -> checkNotNull(LocalWireSharedViewModelStoreOwners.current[owner]) { + "Shared owner ${owner.stableKey()} is not available to the current route" + } + } + +/** + * Shared-owner decorator based on Navigation 3's shared ViewModel recipe. + * + * The standard entry decorator remains the default [LocalViewModelStoreOwner]. This decorator + * retains additional flow, session and application stores under stable typed owner keys. + */ +class WireViewModelStoreNavEntryDecorator private constructor( + private val registry: WireViewModelStoreRegistry, + private val onOwnerAvailable: (WireViewModelOwner, ViewModelStoreOwner) -> Unit, + private val onOwnerReleased: (WireViewModelOwner, ViewModelStoreOwner) -> Unit, +) : NavEntryDecorator( + onPop = registry::onEntryPopped, + decorate = { entry -> + val entryOwnerIdentity = checkNotNull(entry.metadata[EntryOwnerKey]) { + "Wire entry ${entry.contentKey} has no typed ViewModel owner metadata; register it with wireEntry" + } + val sharedOwnerIdentities = entry.metadata[SharedOwnersKey].orEmpty() + DisposableEffect(entry.contentKey, sharedOwnerIdentities) { + registry.registerEntry(entry.contentKey, sharedOwnerIdentities) + onDispose { } + } + + val entryOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "The standard Navigation 3 entry ViewModelStore decorator must run first" + } + DisposableEffect(entryOwnerIdentity, entryOwner) { + onOwnerAvailable(entryOwnerIdentity, entryOwner) + onDispose { + onOwnerReleased(entryOwnerIdentity, entryOwner) + } + } + val sharedOwners = linkedMapOf() + sharedOwnerIdentities + .sortedBy(WireViewModelOwner::stableKey) + .forEach { ownerIdentity -> + key(ownerIdentity.stableKey()) { + val owner = rememberViewModelStoreOwner( + key = ownerIdentity.stableKey(), + provider = registry.storeProvider, + ) + DisposableEffect(ownerIdentity, owner) { + onOwnerAvailable(ownerIdentity, owner) + onDispose { + onOwnerReleased(ownerIdentity, owner) + } + } + sharedOwners[ownerIdentity] = owner + } + } + CompositionLocalProvider( + LocalWireViewModelOwner provides entryOwnerIdentity, + LocalWireSharedViewModelStoreOwners provides sharedOwners, + ) { + entry.Content() + } + }, +) { + /** + * Explicitly releases a session or application owner after its navigation entries stop using + * it. Flow owners are released automatically when their last entry is popped. + */ + fun clear(owner: WireViewModelOwner) { + require(owner !is WireViewModelOwner.Entry) { + "Entry owners are cleared by the standard Navigation 3 decorator" + } + registry.clear(owner) + } + + constructor( + storeProvider: ViewModelStoreProvider, + onOwnerAvailable: (WireViewModelOwner, ViewModelStoreOwner) -> Unit = { _, _ -> }, + onOwnerReleased: (WireViewModelOwner, ViewModelStoreOwner) -> Unit = { _, _ -> }, + onOwnerCleared: (String) -> Unit = {}, + ) : this( + registry = WireViewModelStoreRegistry(storeProvider, onOwnerCleared), + onOwnerAvailable = onOwnerAvailable, + onOwnerReleased = onOwnerReleased, + ) + + companion object { + private object EntryOwnerKey : NavMetadataKey + private object SharedOwnersKey : NavMetadataKey> + + fun owners( + entryOwner: WireViewModelOwner.Entry, + sharedOwners: Set, + ) = metadata { + put(EntryOwnerKey, entryOwner) + put(SharedOwnersKey, sharedOwners) + } + + fun flow(flowId: String?) = metadata { + if (flowId != null) { + put(SharedOwnersKey, setOf(WireViewModelOwner.Flow(flowId))) + } + } + } +} + +internal class WireViewModelStoreRegistry( + internal val storeProvider: ViewModelStoreProvider, + private val onOwnerCleared: (String) -> Unit = {}, +) { + private val ownershipTracker = WireViewModelOwnershipTracker(::clearOwner) + + fun registerEntry(contentKey: Any, owners: Set) { + ownershipTracker.registerEntry(contentKey, owners) + } + + fun viewModelStoreFor(owner: WireViewModelOwner) = + storeProvider.getOrCreate(owner.stableKey()) + + fun onEntryPopped(contentKey: Any) { + ownershipTracker.onEntryPopped(contentKey) + } + + fun clearSession(sessionId: WireSessionId) { + clear(WireViewModelOwner.Session(sessionId)) + } + + fun clearApplication() { + clear(WireViewModelOwner.Application) + } + + fun clear(owner: WireViewModelOwner) { + clearOwner(owner.stableKey()) + } + + private fun clearOwner(ownerKey: String) { + clearWireViewModelStoreOwner(storeProvider, ownerKey) { + onOwnerCleared(ownerKey) + } + } +} + +private class OwnerClearObserver( + private val onOwnerCleared: () -> Unit, +) : ViewModel() { + override fun onCleared() { + onOwnerCleared() + } +} + +/** + * Marks a shared owner for removal and reports only the actual store clear. Lifecycle defers that + * clear while an exit animation or another composition still holds a provider token. + */ +fun clearWireViewModelStoreOwner( + provider: ViewModelStoreProvider, + key: String, + onCleared: () -> Unit = {}, +) { + val store = provider.getOrCreate(key) + ViewModelProvider.create( + store = store, + factory = viewModelFactory { + initializer { OwnerClearObserver(onCleared) } + }, + )[OwnerClearObserver::class] + provider.clearKey(key) +} + +internal class WireViewModelOwnershipTracker( + private val onOwnerReleased: (String) -> Unit, +) { + private val entryOwners = mutableMapOf>() + + fun registerEntry(contentKey: Any, owners: Set) { + val previous = entryOwners.put(contentKey, owners).orEmpty() + (previous - owners).forEach(::releaseIfUnreferenced) + } + + fun onEntryPopped(contentKey: Any) { + entryOwners.remove(contentKey).orEmpty().forEach(::releaseIfUnreferenced) + } + + private fun releaseIfUnreferenced(owner: WireViewModelOwner) { + if (owner !is WireViewModelOwner.Entry && owner !is WireViewModelOwner.Flow) return + if (entryOwners.values.none { owner in it }) { + onOwnerReleased(owner.stableKey()) + } + } +} + +@Composable +fun rememberWireViewModelStoreProvider( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No parent ViewModelStoreOwner was provided for Wire ViewModels" + }, +): ViewModelStoreProvider = rememberViewModelStoreProvider( + key = "wire-navigation-view-model-owners", + parent = viewModelStoreOwner, +) + +/** Typed Wire name for Lifecycle's shared-owner composable API. */ +@Composable +fun rememberWireSharedViewModelStoreOwner( + key: String, + provider: ViewModelStoreProvider, +): ViewModelStoreOwner = rememberViewModelStoreOwner( + key = key, + provider = provider, +) + +@Composable +fun rememberWireViewModelStoreNavEntryDecorator( + storeProvider: ViewModelStoreProvider, + onOwnerAvailable: (WireViewModelOwner, ViewModelStoreOwner) -> Unit = { _, _ -> }, + onOwnerReleased: (WireViewModelOwner, ViewModelStoreOwner) -> Unit = { _, _ -> }, + onOwnerCleared: (String) -> Unit = {}, +): WireViewModelStoreNavEntryDecorator = + androidx.compose.runtime.remember( + storeProvider, + onOwnerAvailable, + onOwnerReleased, + onOwnerCleared, + ) { + WireViewModelStoreNavEntryDecorator( + storeProvider = storeProvider, + onOwnerAvailable = onOwnerAvailable, + onOwnerReleased = onOwnerReleased, + onOwnerCleared = onOwnerCleared, + ) + } + +@Composable +fun rememberWireViewModelStoreNavEntryDecorator( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No parent ViewModelStoreOwner was provided for Wire ViewModels" + }, +): WireViewModelStoreNavEntryDecorator = + rememberWireViewModelStoreNavEntryDecorator( + rememberWireViewModelStoreProvider(viewModelStoreOwner) + ) diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundStyle.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundStyle.kt deleted file mode 100644 index e1c0cb3a3b0..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundStyle.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.style - -interface BackgroundStyle { - fun backgroundType(): BackgroundType = BackgroundType.Default -} - -enum class BackgroundType { - Default, Auth -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundType.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundType.kt new file mode 100644 index 00000000000..2a2b7207d74 --- /dev/null +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/BackgroundType.kt @@ -0,0 +1,17 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.style + +/** Activity chrome used around the typed Navigation 3 host. */ +enum class BackgroundType { + Default, + Auth, +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/DefaultNavGraphAnimations.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/DefaultNavGraphAnimations.kt deleted file mode 100644 index 9002fef62dd..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/DefaultNavGraphAnimations.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.style - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.navigation.NavBackStackEntry -import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle -import com.ramcosta.composedestinations.spec.DestinationStyle - -object DefaultRootNavGraphAnimations : NavHostAnimatedDestinationStyle() { - override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = DefaultNavigationAnimation.enterTransition - - override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = DefaultNavigationAnimation.exitTransition - - override val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = DefaultNavigationAnimation.popEnterTransition - - override val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = DefaultNavigationAnimation.popExitTransition -} - -object DefaultNestedNavGraphAnimations : DestinationStyle.Animated() { - override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = DefaultNavigationAnimation.enterTransition - - override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = DefaultNavigationAnimation.exitTransition - - override val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = DefaultNavigationAnimation.popEnterTransition - - override val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = DefaultNavigationAnimation.popExitTransition -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt deleted file mode 100644 index 226c8ad6465..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/NavigationAnimationStyles.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.navigation.style - -import androidx.compose.ui.window.DialogProperties -import com.ramcosta.composedestinations.spec.DestinationStyle - -typealias DefaultNavigationAnimation = SlideNavigationAnimation - -object SlideNavigationAnimation : WireDestinationStyleAnimated(), BackgroundStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.SLIDE -} - -object PopUpNavigationAnimation : WireDestinationStyleAnimated(), BackgroundStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.POP_UP -} - -object AuthSlideNavigationAnimation : WireDestinationStyleAnimated(), BackgroundStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.SLIDE - override fun backgroundType(): BackgroundType = BackgroundType.Auth -} - -object AuthPopUpNavigationAnimation : WireDestinationStyleAnimated(), BackgroundStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.POP_UP - override fun backgroundType(): BackgroundType = BackgroundType.Auth -} - -object AuthNoNavigationAnimation : WireDestinationStyleAnimated(), BackgroundStyle { - override fun animationType(): TransitionAnimationType = TransitionAnimationType.NONE - override fun backgroundType(): BackgroundType = BackgroundType.Auth -} - -object DialogNavigation : DestinationStyle.Dialog() { - override val properties: DialogProperties - get() = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - usePlatformDefaultWidth = true, - decorFitsSystemWindows = false, - ) -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/TransitionAnimations.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/TransitionAnimations.kt index e71e3c3e07c..de9e3e7d8e6 100644 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/TransitionAnimations.kt +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/TransitionAnimations.kt @@ -117,3 +117,32 @@ internal fun fadeOutFromView(): ExitTransition { animationSpec = fadeAnimationSpec, ) } + +/** + * Transition animations shared by Navigation 3 entries and standalone Activity hosts. + */ +enum class TransitionAnimationType( + val enterTransition: EnterTransition, + val exitTransition: ExitTransition, + val popEnterTransition: EnterTransition, + val popExitTransition: ExitTransition, +) { + SLIDE( + enterTransition = smoothSlideInFromRight(), + exitTransition = fadeOutFromView(), + popEnterTransition = fadeInToView(), + popExitTransition = smoothSlideOutFromLeft(), + ), + POP_UP( + enterTransition = expandInToView(), + exitTransition = fadeOutFromView(), + popEnterTransition = fadeInToView(), + popExitTransition = shrinkOutFromView(), + ), + NONE( + enterTransition = EnterTransition.None, + exitTransition = ExitTransition.None, + popEnterTransition = EnterTransition.None, + popExitTransition = ExitTransition.None, + ), +} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/WireDestinationStyleAnimated.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/style/WireDestinationStyleAnimated.kt deleted file mode 100644 index af4968dafe8..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/style/WireDestinationStyleAnimated.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.style - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.navigation.NavBackStackEntry -import com.ramcosta.composedestinations.spec.TypedDestinationSpec -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.ramcosta.composedestinations.utils.destination - -/** - * Default transition animations style class. - * Chooses the right transition animation to be used depending on the recently opened destination's style. - * Thanks to that animations are consistent and not mixed when both destinations involved in the transition use different styles. - */ -abstract class WireDestinationStyleAnimated : DestinationStyle.Animated() { - open fun animationType(): TransitionAnimationType = TransitionAnimationType.SLIDE - private fun TypedDestinationSpec<*>.getAnimationTypeStyle() = - (this.style as? WireDestinationStyleAnimated)?.animationType() ?: animationType() - - private fun NavBackStackEntry.getAnimationTypeStyleOrDefault() = - runCatching { destination().getAnimationTypeStyle() }.getOrDefault(animationType()) - - override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = { - targetState.getAnimationTypeStyleOrDefault().enterTransition - } - - override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = { - targetState.getAnimationTypeStyleOrDefault().exitTransition - } - - override val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition - get() = { - initialState.getAnimationTypeStyleOrDefault().popEnterTransition - } - - override val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition - get() = { - initialState.getAnimationTypeStyleOrDefault().popExitTransition - } -} - -/** - * Transition animations according to the Wire design system documentation. - * - * navigate A -> B - * A style B style A exit animation B enter animation - * Slide Slide FadeOut SlideFromRight - * Slide PopUp FadeOut SlideFromBottom - * PopUp Slide FadeOut SlideFromRight - * PopUp PopUp FadeOut SlideFromBottom - * - * navigate back B -> A - * A style B style B popExit animation A popEnter animation - * Slide Slide SlideToRight FadeIn - * Slide PopUp SlideToBottom FadeIn - * PopUp Slide SlideToRight FadeIn - * PopUp PopUp SlideToBottom FadeIn - */ -enum class TransitionAnimationType( - val enterTransition: EnterTransition, - val exitTransition: ExitTransition, - val popEnterTransition: EnterTransition, - val popExitTransition: ExitTransition -) { - SLIDE( // new destination slides from right - enterTransition = smoothSlideInFromRight(), - exitTransition = fadeOutFromView(), - popEnterTransition = fadeInToView(), - popExitTransition = smoothSlideOutFromLeft() - ), - POP_UP( // new destination slides from bottom - enterTransition = expandInToView(), - exitTransition = fadeOutFromView(), - popEnterTransition = fadeInToView(), - popExitTransition = shrinkOutFromView() - ), - NONE( - enterTransition = EnterTransition.None, - exitTransition = ExitTransition.None, - popEnterTransition = EnterTransition.None, - popExitTransition = ExitTransition.None - ) -} diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/TabletDialogWrapper.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/TabletDialogWrapper.kt deleted file mode 100644 index 264c883aa87..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/TabletDialogWrapper.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.wrapper - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import com.ramcosta.composedestinations.scope.DestinationScope -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.ramcosta.composedestinations.wrapper.DestinationWrapper -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.theme.isTablet - -object TabletDialogWrapper : DestinationWrapper { - - @Composable - override fun DestinationScope.Wrap(screenContent: @Composable () -> Unit) { - val movableContent = remember(screenContent) { movableContentOf(screenContent) } - val shouldWrapAsDialog = destination.style is DestinationStyle.Dialog || - (isTablet && shouldWrapTabletRouteAsDialog(destination.route)) - if (shouldWrapAsDialog) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(dimensions().spacing20x)) - .imePadding() - ) { - movableContent() - } - } else { - movableContent() - } - } -} - -fun setTabletDialogRouteMatcher(routeMatcher: (String) -> Boolean) { - shouldWrapTabletRouteAsDialog = routeMatcher -} - -@Volatile -private var shouldWrapTabletRouteAsDialog: (String) -> Boolean = { false } diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/WaitUntilTransitionEndsWrapper.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/WaitUntilTransitionEndsWrapper.kt deleted file mode 100644 index cb5c26a7cb9..00000000000 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/wrapper/WaitUntilTransitionEndsWrapper.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.wrapper - -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import com.ramcosta.composedestinations.scope.AnimatedDestinationScope -import com.ramcosta.composedestinations.scope.DestinationScope -import com.ramcosta.composedestinations.wrapper.DestinationWrapper - -@OptIn(ExperimentalAnimationApi::class) -object WaitUntilTransitionEndsWrapper : DestinationWrapper { - - @Composable - override fun DestinationScope.Wrap(screenContent: @Composable () -> Unit) { - val movableContent = remember(screenContent) { movableContentOf(screenContent) } - if (this is AnimatedDestinationScope) { - var transitionComplete by remember { mutableStateOf(false) } - LaunchedEffect(transition.isRunning, transition.currentState, transition.targetState) { - with(transition) { - transitionComplete = !isRunning && currentState == targetState && currentState == EnterExitState.Visible - } - } - movableContent() - if (!transitionComplete) { - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - // empty, do nothing to prevent clicks - } - ) - } - } else { - movableContent() - } - } -} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/Navigation3OnlyModuleSourceTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/Navigation3OnlyModuleSourceTest.kt new file mode 100644 index 00000000000..d228d81d92e --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/Navigation3OnlyModuleSourceTest.kt @@ -0,0 +1,55 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.navigation3 + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +class Navigation3OnlyModuleSourceTest { + + @Test + fun givenNavigationModule_whenInspectingSourcesAndDependencies_thenLegacyNavigationCannotReturn() { + val moduleRoot = moduleRoot() + val productionSources = File(moduleRoot, "src/main/kotlin") + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .toList() + + productionSources.forEach { sourceFile -> + val source = sourceFile.readText() + listOf( + "import com.ramcosta", + "import androidx.navigation.NavController", + "import androidx.navigation.NavBackStackEntry", + "DestinationStyle", + "DestinationWrapper", + ).forEach { forbidden -> + assertFalse( + forbidden in source, + "${sourceFile.relativeTo(moduleRoot)} still contains $forbidden", + ) + } + } + + val buildScript = File(moduleRoot, "build.gradle.kts").readText() + listOf( + "libs.compose.navigation", + "libs.compose.destinations", + ).forEach { forbidden -> + assertFalse(forbidden in buildScript, "build.gradle.kts still contains $forbidden") + } + } + + private fun moduleRoot(): File = + generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "src/main/kotlin/com/wire/android/navigation").isDirectory } +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentationTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentationTest.kt new file mode 100644 index 00000000000..29342079c8c --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireEntryPresentationTest.kt @@ -0,0 +1,124 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.ui.window.DialogProperties +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireEntryPresentationTest { + + @Test + fun givenDefaultPresentation_whenResolved_thenItUsesTheCurrentSlideProfile() { + assertEquals(WireEntryPresentation.Slide, WireEntryPresentation.Default) + assertEquals( + WireEntryTransitionProfile.Slide, + WireEntryPresentation.Default.transitionProfile, + ) + assertTransitionMetadataIsComplete(WireEntryPresentation.Default) + } + + @Test + fun givenEachAnimatedPresentation_whenMetadataIsCreated_thenAllTransitionDirectionsAreDefined() { + assertEquals( + WireEntryTransitionProfile.Slide, + WireEntryPresentation.Slide.transitionProfile, + ) + assertEquals( + WireEntryTransitionProfile.PopUp, + WireEntryPresentation.PopUp.transitionProfile, + ) + assertEquals( + WireEntryTransitionProfile.None, + WireEntryPresentation.None.transitionProfile, + ) + + assertTransitionMetadataIsComplete(WireEntryPresentation.Slide) + assertTransitionMetadataIsComplete(WireEntryPresentation.PopUp) + assertTransitionMetadataIsComplete(WireEntryPresentation.None) + } + + @Test + fun givenDialogPresentation_whenMetadataIsCreated_thenDialogPropertiesArePreserved() { + val properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) + + val metadata = WireEntryPresentation.dialog(properties).navEntryMetadata + + assertEquals(DialogSceneStrategy.dialog(properties), metadata) + assertEquals( + WireEntryTransitionProfile.Dialog, + WireEntryPresentation.dialog(properties).transitionProfile, + ) + } + + @Test + fun givenBlockUntilSettledPolicy_whenTransitionRunsBeforeResume_thenInputIsBlocked() { + val policy = WireTransitionInteractionPolicy.BlockUntilSettled + + assertTrue(policy.shouldBlockInput(isTransitionRunning = true, isEntryResumed = false)) + } + + @Test + fun givenLifecycleLagsAfterTransition_whenEvaluatingInput_thenInputIsAllowed() { + assertFalse( + WireTransitionInteractionPolicy.BlockUntilSettled.shouldBlockInput( + isTransitionRunning = false, + isEntryResumed = false, + ) + ) + } + + @Test + fun givenAnimationSignalIsStaleAfterResume_whenEvaluatingInput_thenInputIsAllowed() { + assertFalse( + WireTransitionInteractionPolicy.BlockUntilSettled.shouldBlockInput( + isTransitionRunning = true, + isEntryResumed = true, + ) + ) + } + + @Test + fun givenAllowDuringTransitionPolicy_whenTransitionRuns_thenInputRemainsEnabled() { + assertFalse( + WireTransitionInteractionPolicy.AllowDuringTransition.shouldBlockInput( + isTransitionRunning = true, + isEntryResumed = false, + ) + ) + } + + private fun assertTransitionMetadataIsComplete(presentation: WireEntryPresentation) { + val transitionKey = NavDisplay.transitionSpec { null }.keys.single() + val popTransitionKey = NavDisplay.popTransitionSpec { null }.keys.single() + val predictivePopTransitionKey = + NavDisplay.predictivePopTransitionSpec { null }.keys.single() + + assertTrue(transitionKey in presentation.navEntryMetadata) + assertTrue(popTransitionKey in presentation.navEntryMetadata) + assertTrue(predictivePopTransitionKey in presentation.navEntryMetadata) + } +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireNavigation3RuntimeTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireNavigation3RuntimeTest.kt new file mode 100644 index 00000000000..29bc63b048a --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireNavigation3RuntimeTest.kt @@ -0,0 +1,259 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireRoute +import com.wire.navigation.availableSharedViewModelOwners +import com.wire.navigation.entryViewModelOwner +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireNavigation3RuntimeTest { + private val resultType = WireNavigation3ResultType( + contract = WireNavResultContract(WireNavResultContractId("string")), + serializer = String.serializer(), + ) + + @Test + fun givenEqualRoutes_whenBuildingEntries_thenEachInstanceHasIndependentStableContentKey() { + val first = route("details", entryId = "first") + val second = route("details", entryId = "second") + val provider = entryProvider { + wireEntry { } + } + + assertEquals("first", provider(first).contentKey) + assertEquals("second", provider(second).contentKey) + } + + @Test + fun givenCustomMetadataConflictsWithWireMetadata_whenBuildingEntry_thenWirePoliciesWin() { + val route = TestRoute( + routeId = "details", + entryId = WireNavEntryId("details"), + flowId = "real-flow", + ) + val presentation = WireEntryPresentation.PopUp + val transitionKey = NavDisplay.transitionSpec { null }.keys.single() + val ownerMetadata = WireViewModelStoreNavEntryDecorator.owners( + entryOwner = route.entryViewModelOwner(), + sharedOwners = route.availableSharedViewModelOwners(), + ) + val provider = entryProvider { + wireEntry( + presentation = presentation, + metadata = mapOf( + "feature-metadata" to "preserved", + transitionKey to "feature-transition", + ) + ownerMetadata.keys.associateWith { "spoofed-owner" }, + ) { } + } + + val metadata = provider(route).metadata + + assertEquals("preserved", metadata["feature-metadata"]) + assertSame(presentation.navEntryMetadata[transitionKey], metadata[transitionKey]) + ownerMetadata.forEach { (key, value) -> + assertEquals(value, metadata[key]) + } + } + + @Test + fun givenPendingRequest_whenTargetGoesBackNormally_thenRequesterConsumesCancellation() { + val runtime = runtime(route("home", entryId = "home")) + val requestId = runtime.navigateForResult( + destination = route("picker", entryId = "picker"), + resultType = resultType, + ) + + assertTrue(runtime.navigator.goBack()) + assertEquals( + WireNavResult.Canceled, + runtime.consumeResult(requireNotNull(requestId), resultType), + ) + } + + @Test + fun givenPendingRequest_whenCompletingAndPopping_thenValueAndPopHappenExactlyOnce() { + val runtime = runtime(route("home", entryId = "home")) + val requestId = requireNotNull( + runtime.navigateForResult(route("picker", entryId = "picker"), resultType) + ) + + assertTrue(runtime.completeAndPop(requestId, resultType, WireNavResult.Value("selected"))) + assertEquals(listOf("home"), runtime.navigator.routes.map { it.entryId.value }) + assertFalse(runtime.completeAndPop(requestId, resultType, WireNavResult.Value("duplicate"))) + assertEquals(WireNavResult.Value("selected"), runtime.consumeResult(requestId, resultType)) + assertNull(runtime.consumeResult(requestId, resultType)) + } + + @Test + fun givenUniquePendingRequestForCurrentTarget_whenCompletingCurrent_thenValueIsDelivered() { + val runtime = runtime(route("home", entryId = "home")) + val requestId = requireNotNull( + runtime.navigateForResult(route("picker", entryId = "picker"), resultType) + ) + + assertTrue( + runtime.completeCurrentAndPop( + resultType = resultType, + result = WireNavResult.Value("selected"), + ) + ) + + assertEquals(listOf("home"), runtime.navigator.routes.map { it.entryId.value }) + assertEquals(WireNavResult.Value("selected"), runtime.consumeResult(requestId, resultType)) + } + + @Test + fun givenCurrentEntryHasNoPendingRequest_whenCompletingCurrent_thenStackIsUnchanged() { + val runtime = runtime(route("home", entryId = "home")) + + assertFalse( + runtime.completeCurrentAndPop( + resultType = resultType, + result = WireNavResult.Value("unexpected"), + ) + ) + + assertEquals(listOf("home"), runtime.navigator.routes.map { it.entryId.value }) + } + + @Test + fun givenSavedCompletedResult_whenRuntimeIsRestored_thenRequesterConsumesItOnce() { + val requester = route("home", entryId = "home") + val originalStore = WireNavigation3ResultStore(listOf(resultType)) + val runtime = runtime(requester, resultStore = originalStore) + val requestId = requireNotNull( + runtime.navigateForResult(route("picker", entryId = "picker"), resultType) + ) + assertTrue(runtime.completeAndPop(requestId, resultType, WireNavResult.Value("restored"))) + + val restoredStore = WireNavigation3ResultStore( + resultTypes = listOf(resultType), + restored = originalStore.encode(), + ) + val restoredRuntime = runtime(requester, resultStore = restoredStore) + + assertEquals( + WireNavResult.Value("restored"), + restoredRuntime.consumeResult(requestId, resultType), + ) + assertNull(restoredRuntime.consumeResult(requestId, resultType)) + } + + @Test + fun givenSavedNullableValue_whenRuntimeIsRestored_thenNullValueIsNotCancellation() { + val nullableType = WireNavigation3ResultType( + contract = WireNavResultContract(WireNavResultContractId("nullable-string")), + serializer = String.serializer().nullable, + ) + val requester = route("home", entryId = "home") + val originalStore = WireNavigation3ResultStore(listOf(nullableType)) + val runtime = runtime( + requester, + resultStore = originalStore, + ) + val requestId = requireNotNull( + runtime.navigateForResult(route("picker", entryId = "picker"), nullableType) + ) + assertTrue(runtime.completeAndPop(requestId, nullableType, WireNavResult.Value(null))) + + val restoredRuntime = runtime( + requester, + resultStore = WireNavigation3ResultStore( + resultTypes = listOf(nullableType), + restored = originalStore.encode(), + ), + ) + + assertEquals(WireNavResult.Value(null), restoredRuntime.consumeResult(requestId, nullableType)) + } + + @Test + fun givenPendingRequest_whenStackIsReplaced_thenBrokenRelationshipIsPruned() { + val runtime = runtime(route("home", entryId = "home")) + val requestId = requireNotNull( + runtime.navigateForResult(route("picker", entryId = "picker"), resultType) + ) + + runtime.navigator.replaceBackStack(listOf(route("login", entryId = "login"))) + + assertFalse(runtime.completeAndPop(requestId, resultType, WireNavResult.Value("stale"))) + assertNull(runtime.consumeResult(requestId, resultType)) + } + + @Test + fun givenNavigationIsRejected_whenRequestingResult_thenNoRequestOrTargetIsCreated() { + val runtime = runtime( + route("home", entryId = "home"), + canNavigate = { false }, + ) + + val requestId = runtime.navigateForResult( + route("picker", entryId = "picker"), + resultType, + ) + + assertNull(requestId) + assertEquals(listOf("home"), runtime.navigator.routes.map { it.entryId.value }) + } + + private fun runtime( + vararg routes: TestRoute, + resultStore: WireNavigation3ResultStore = WireNavigation3ResultStore(listOf(resultType)), + canNavigate: (com.wire.navigation.WireNavigationCommand) -> Boolean = { true }, + ): WireNavigation3Runtime { + var nextRequest = 0 + return WireNavigation3Runtime( + backStack = NavBackStack(*routes.map { it as NavKey }.toTypedArray()), + resultStore = resultStore, + canNavigate = canNavigate, + requestIdFactory = { WireNavResultRequestId("request-${nextRequest++}") }, + ) + } + + private fun route(routeId: String, entryId: String) = TestRoute( + routeId = routeId, + entryId = WireNavEntryId(entryId), + ) + + @Serializable + private data class TestRoute( + override val routeId: String, + override val entryId: WireNavEntryId, + override val flowId: String? = null, + ) : WireRoute +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicyTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicyTest.kt new file mode 100644 index 00000000000..4e386803a54 --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireResponsivePresentationPolicyTest.kt @@ -0,0 +1,172 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.compose.ui.window.DialogProperties +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.DialogSceneStrategy +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireRoute +import kotlinx.serialization.Serializable +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WireResponsivePresentationPolicyTest { + + @Test + fun givenRegisteredRouteOnPhone_whenEntryIsResolved_thenOriginalEntryIsUnchanged() { + val route = TabletRoute() + val original = entry(route, metadata = mapOf("feature" to "metadata")) + val provider = responsiveEntryProvider( + delegate = { original }, + policy = routeTypePolicy(), + isTablet = false, + ) + + assertSame(original, provider(route)) + } + + @Test + fun givenUnregisteredRouteOnTablet_whenEntryIsResolved_thenOriginalEntryIsUnchanged() { + val route = PhoneRoute() + val original = entry(route) + val provider = responsiveEntryProvider( + delegate = { original }, + policy = routeTypePolicy(), + isTablet = true, + ) + + assertSame(original, provider(route)) + } + + @Test + fun givenRegisteredRouteOnTablet_whenEntryIsResolved_thenLegacyDialogPropertiesAreApplied() { + val route = TabletRoute() + val original = entry(route, metadata = mapOf("feature" to "metadata")) + val provider = responsiveEntryProvider( + delegate = { original }, + policy = routeTypePolicy(), + isTablet = true, + ) + + val resolved = provider(route) + + assertEquals("metadata", resolved.metadata["feature"]) + assertTrue( + DialogSceneStrategy.dialog(WireTabletDialogProperties).all { (key, value) -> + resolved.metadata[key] == value + } + ) + assertEquals(original.contentKey, resolved.contentKey) + } + + @Test + fun givenExplicitDialogOnTablet_whenResponsivePolicyAlsoMatches_thenExplicitPropertiesWin() { + val explicitProperties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ) + val route = TabletRoute() + val original = entry( + route, + metadata = WireEntryPresentation.dialog(explicitProperties).navEntryMetadata, + ) + val provider = responsiveEntryProvider( + delegate = { original }, + policy = routeTypePolicy(), + isTablet = true, + ) + + val resolved = provider(route) + + assertTrue( + DialogSceneStrategy.dialog(explicitProperties).all { (key, value) -> + resolved.metadata[key] == value + } + ) + } + + @Test + fun givenTypedPredicate_whenRouteDataMatches_thenOnlyMatchingInstanceUsesDialog() { + val policy = WireResponsivePresentationPolicy.tabletDialogsWhere { route -> + route is TabletRoute && route.useDialog + } + + assertTrue( + policy.shouldPresentAsTabletDialog( + TabletRoute(useDialog = true), + isTablet = true, + ) + ) + assertFalse( + policy.shouldPresentAsTabletDialog( + TabletRoute(useDialog = false), + isTablet = true, + ) + ) + } + + @Test + fun givenPhoneLayout_whenPresentationIsResolved_thenExplicitPresentationIsPreserved() { + val route = TabletRoute() + + assertSame( + WireEntryPresentation.PopUp, + routeTypePolicy().resolve( + route = route, + isTablet = false, + defaultPresentation = WireEntryPresentation.PopUp, + ), + ) + } + + private fun routeTypePolicy(): WireResponsivePresentationPolicy = + WireResponsivePresentationPolicy.tabletDialogsFor(setOf(TabletRoute::class)) + + private fun entry( + route: TestRoute, + metadata: Map = emptyMap(), + ): NavEntry = NavEntry( + key = route, + contentKey = route.entryId.value, + metadata = metadata, + ) { } + + private sealed interface TestRoute : WireRoute + + @Serializable + private data class TabletRoute( + val useDialog: Boolean = true, + override val entryId: WireNavEntryId = WireNavEntryId("tablet"), + ) : TestRoute { + override val routeId: String = "tablet" + } + + @Serializable + private data class PhoneRoute( + override val entryId: WireNavEntryId = WireNavEntryId("phone"), + ) : TestRoute { + override val routeId: String = "phone" + } +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireSharedViewModelStoreOwnerTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireSharedViewModelStoreOwnerTest.kt new file mode 100644 index 00000000000..5c7ac243928 --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireSharedViewModelStoreOwnerTest.kt @@ -0,0 +1,156 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import androidx.savedstate.SavedState +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.read +import androidx.savedstate.savedState +import kotlin.reflect.KClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [35]) +class WireSharedViewModelStoreOwnerTest { + + @Test + fun givenStableStoreAndKey_whenOwnerWrapperChanges_thenExistingViewModelIsReused() { + val provider = ViewModelStoreProvider(parentStore = null) + val markerKey = CreationExtras.Key() + val firstRegistryOwner = TestSavedStateRegistryOwner() + val firstOwner = provider.owner("flow:login", firstRegistryOwner, TestFactory(markerKey)) + firstRegistryOwner.moveToCreated() + val first = ViewModelProvider.create(firstOwner)[TestSavedStateViewModel::class] + + val secondRegistryOwner = TestSavedStateRegistryOwner() + val secondOwner = provider.owner("flow:login", secondRegistryOwner, TestFactory(markerKey)) + secondRegistryOwner.moveToCreated() + val second = ViewModelProvider.create(secondOwner)[TestSavedStateViewModel::class] + + assertSame(first, second) + } + + @Test + fun givenParentFactoryAndExtras_whenFlowViewModelIsCreated_thenBothReachTheFactory() { + val savedStateOwner = TestSavedStateRegistryOwner() + val markerKey = CreationExtras.Key() + val factory = TestFactory(markerKey) + val owner = ViewModelStoreProvider(parentStore = null).getOrCreateOwner( + key = "flow:login", + savedStateRegistryOwner = savedStateOwner, + defaultFactory = factory, + defaultCreationExtras = MutableCreationExtras().apply { this[markerKey] = METRO_MARKER }, + ) + savedStateOwner.moveToCreated() + + val viewModel = ViewModelProvider.create(owner)[TestSavedStateViewModel::class] + + assertEquals(METRO_MARKER, factory.receivedMarker) + assertEquals(0, viewModel.savedStateHandle[COUNT_KEY]) + } + + @Test + fun givenFlowViewModelState_whenOwnerIsRecreated_thenSavedStateHandleIsRestored() { + val markerKey = CreationExtras.Key() + val firstRegistryOwner = TestSavedStateRegistryOwner() + val firstFlowOwner = ViewModelStoreProvider(parentStore = null) + .owner("flow:login", firstRegistryOwner, TestFactory(markerKey)) + firstRegistryOwner.moveToCreated() + ViewModelProvider.create(firstFlowOwner)[TestSavedStateViewModel::class] + .savedStateHandle[COUNT_KEY] = RESTORED_COUNT + val savedState = firstRegistryOwner.save() + assertTrue(savedState.read { !isEmpty() }) + + val restoredRegistryOwner = TestSavedStateRegistryOwner(savedState) + val restoredFlowOwner = ViewModelStoreProvider(parentStore = null) + .owner("flow:login", restoredRegistryOwner, TestFactory(markerKey)) + restoredRegistryOwner.moveToCreated() + + val restored = ViewModelProvider.create(restoredFlowOwner)[TestSavedStateViewModel::class] + + assertEquals(RESTORED_COUNT, restored.savedStateHandle[COUNT_KEY]) + } + + private fun ViewModelStoreProvider.owner( + key: String, + savedStateRegistryOwner: SavedStateRegistryOwner, + factory: ViewModelProvider.Factory, + ) = getOrCreateOwner( + key = key, + savedStateRegistryOwner = savedStateRegistryOwner, + defaultFactory = factory, + defaultCreationExtras = MutableCreationExtras(), + ) + + private class TestFactory( + private val markerKey: CreationExtras.Key, + ) : ViewModelProvider.Factory { + var receivedMarker: String? = null + + override fun create(modelClass: KClass, extras: CreationExtras): T { + receivedMarker = extras[markerKey] + @Suppress("UNCHECKED_CAST") + return TestSavedStateViewModel(extras.createSavedStateHandle()) as T + } + } + + private class TestSavedStateViewModel( + val savedStateHandle: SavedStateHandle, + ) : ViewModel() { + init { + if (!savedStateHandle.contains(COUNT_KEY)) savedStateHandle[COUNT_KEY] = 0 + } + } + + private class TestSavedStateRegistryOwner( + restoredState: SavedState? = null, + ) : SavedStateRegistryOwner { + private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this) + private val controller = SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry = controller.savedStateRegistry + + init { + controller.performAttach() + controller.performRestore(restoredState) + } + + fun moveToCreated() { + lifecycleRegistry.currentState = Lifecycle.State.CREATED + } + + fun save(): SavedState = savedState().also(controller::performSave) + } + + private companion object { + const val METRO_MARKER = "metro-factory" + const val COUNT_KEY = "count" + const val RESTORED_COUNT = 42 + } +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerFlowTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerFlowTest.kt new file mode 100644 index 00000000000..600d02a9cce --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerFlowTest.kt @@ -0,0 +1,111 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import com.wire.navigation.WireViewModelOwner +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +class WireViewModelOwnershipTrackerFlowTest { + + @Test + fun givenTwoEntriesInFlow_whenFirstEntryIsPopped_thenFlowOwnerIsRetained() { + val releasedFlows = mutableListOf() + val tracker = WireViewModelOwnershipTracker { + releasedFlows += it.removePrefix("flow:") + } + val owner = WireViewModelOwner.Flow("new-conversation") + tracker.registerEntry("first", setOf(owner)) + tracker.registerEntry("second", setOf(owner)) + + tracker.onEntryPopped("first") + + assertEquals(emptyList(), releasedFlows) + } + + @Test + fun givenLastEntryInFlow_whenEntryIsPopped_thenFlowOwnerIsReleased() { + val releasedFlows = mutableListOf() + val tracker = WireViewModelOwnershipTracker { + releasedFlows += it.removePrefix("flow:") + } + val owner = WireViewModelOwner.Flow("new-conversation") + tracker.registerEntry("first", setOf(owner)) + tracker.registerEntry("second", setOf(owner)) + + tracker.onEntryPopped("first") + tracker.onEntryPopped("second") + + assertEquals(listOf("new-conversation"), releasedFlows) + } + + @Test + fun givenEntriesInDifferentFlows_whenOneFlowEnds_thenOnlyItsOwnerIsReleased() { + val releasedFlows = mutableListOf() + val tracker = WireViewModelOwnershipTracker { + releasedFlows += it.removePrefix("flow:") + } + tracker.registerEntry( + "conversation", + setOf(WireViewModelOwner.Flow("new-conversation")), + ) + tracker.registerEntry( + "meeting", + setOf(WireViewModelOwner.Flow("new-meeting")), + ) + + tracker.onEntryPopped("conversation") + + assertEquals(listOf("new-conversation"), releasedFlows) + } + + @Test + fun givenSameFlow_whenStoreIsRequestedAgain_thenStoreIsReused() { + val holder = ViewModelStoreProvider(parentStore = null) + + val first = holder.getOrCreate("flow:new-conversation") + val second = holder.getOrCreate("flow:new-conversation") + + assertSame(first, second) + } + + @Test + fun givenDifferentFlows_whenStoresAreRequested_thenStoresAreIsolated() { + val holder = ViewModelStoreProvider(parentStore = null) + + val conversation = holder.getOrCreate("flow:new-conversation") + val meeting = holder.getOrCreate("flow:new-meeting") + + assertNotSame(conversation, meeting) + } + + @Test + fun givenReleasedFlow_whenItsStoreIsRequestedAgain_thenFreshStoreIsCreated() { + val holder = ViewModelStoreProvider(parentStore = null) + val original = holder.getOrCreate("flow:new-conversation") + + holder.clearKey("flow:new-conversation") + val recreated = holder.getOrCreate("flow:new-conversation") + + assertNotSame(original, recreated) + } +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerTest.kt new file mode 100644 index 00000000000..48deedce1ce --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelOwnershipTrackerTest.kt @@ -0,0 +1,110 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WireViewModelOwnershipTrackerTest { + + @Test + fun givenTwoEntriesShareFlow_whenFirstEntryIsPopped_thenOwnerIsRetained() { + val released = mutableListOf() + val tracker = WireViewModelOwnershipTracker(released::add) + val owner = WireViewModelOwner.Flow("authentication") + tracker.registerEntry("email", setOf(owner)) + tracker.registerEntry("password", setOf(owner)) + + tracker.onEntryPopped("email") + + assertEquals(emptyList(), released) + } + + @Test + fun givenLastFlowEntryIsPopped_whenTrackingOwnership_thenFlowOwnerIsReleased() { + val released = mutableListOf() + val tracker = WireViewModelOwnershipTracker(released::add) + val owner = WireViewModelOwner.Flow("authentication") + tracker.registerEntry("email", setOf(owner)) + tracker.registerEntry("password", setOf(owner)) + + tracker.onEntryPopped("email") + tracker.onEntryPopped("password") + + assertEquals(listOf("flow:authentication"), released) + } + + @Test + fun givenEntryChangesFlowOwner_whenRegisteredAgain_thenUnreferencedOldOwnerIsReleased() { + val released = mutableListOf() + val tracker = WireViewModelOwnershipTracker(released::add) + tracker.registerEntry("entry", setOf(WireViewModelOwner.Flow("old"))) + + tracker.registerEntry("entry", setOf(WireViewModelOwner.Flow("new"))) + + assertEquals(listOf("flow:old"), released) + } + + @Test + fun givenSessionEntryIsPopped_whenTrackingOwnership_thenSessionOwnerRemainsRetained() { + val released = mutableListOf() + val tracker = WireViewModelOwnershipTracker(released::add) + tracker.registerEntry( + contentKey = "conversation", + owners = setOf( + WireViewModelOwner.Session(WireSessionId("user", "wire.example")) + ), + ) + + tracker.onEntryPopped("conversation") + + assertEquals(emptyList(), released) + } + + @Test + fun givenApplicationEntryIsPopped_whenTrackingOwnership_thenApplicationOwnerRemainsRetained() { + val released = mutableListOf() + val tracker = WireViewModelOwnershipTracker(released::add) + tracker.registerEntry("coordinator", setOf(WireViewModelOwner.Application)) + + tracker.onEntryPopped("coordinator") + + assertEquals(emptyList(), released) + } + + @Test + fun givenRouteHasMultipleSharedOwners_whenPopped_thenOnlyFlowOwnerIsReleasedAutomatically() { + val released = mutableListOf() + val tracker = WireViewModelOwnershipTracker(released::add) + tracker.registerEntry( + contentKey = "login", + owners = setOf( + WireViewModelOwner.Flow("login"), + WireViewModelOwner.Session(WireSessionId("user", "wire.example")), + WireViewModelOwner.Application, + ), + ) + + tracker.onEntryPopped("login") + + assertEquals(listOf("flow:login"), released) + } +} diff --git a/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreRegistryTest.kt b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreRegistryTest.kt new file mode 100644 index 00000000000..8f51cdcec10 --- /dev/null +++ b/core/navigation/src/test/kotlin/com/wire/android/navigation/navigation3/WireViewModelStoreRegistryTest.kt @@ -0,0 +1,188 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.navigation.navigation3 + +import androidx.lifecycle.viewmodel.ViewModelStoreProvider +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import com.wire.navigation.WireViewModelOwner +import com.wire.navigation.stableKey +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +class WireViewModelStoreRegistryTest { + + @Test + fun givenSameStableOwnerKey_whenStoreIsAcquiredRepeatedly_thenStoreIsReused() { + val provider = ViewModelStoreProvider(parentStore = null) + + val first = provider.getOrCreate("flow:login") + val second = provider.getOrCreate("flow:login") + + assertSame(first, second) + } + + @Test + fun givenDifferentOwnerKinds_whenStoresAreAcquired_thenStoresAreIsolated() { + val provider = ViewModelStoreProvider(parentStore = null) + + val flow = provider.getOrCreate("flow:shared") + val session = provider.getOrCreate("session:shared") + val application = provider.getOrCreate("application") + + assertNotSame(flow, session) + assertNotSame(flow, application) + assertNotSame(session, application) + } + + @Test + fun givenOwnerIsCleared_whenItIsAcquiredAgain_thenFreshStoreIsCreated() { + val provider = ViewModelStoreProvider(parentStore = null) + val original = provider.getOrCreate("flow:registration") + + provider.clearKey("flow:registration") + + assertNotSame(original, provider.getOrCreate("flow:registration")) + } + + @Test + fun givenOwnerIsStillDisplayed_whenClearIsRequested_thenStoreWaitsForExitAnimation() { + val provider = ViewModelStoreProvider(parentStore = null) + val original = provider.getOrCreate("flow:registration") + val displayedReference = provider.acquireToken("flow:registration") + var clearedCallbacks = 0 + val registry = WireViewModelStoreRegistry(provider) { clearedCallbacks++ } + + registry.clear(WireViewModelOwner.Flow("registration")) + + assertSame(original, provider.getOrCreate("flow:registration")) + assertEquals(0, clearedCallbacks) + + displayedReference.close() + + assertNotSame(original, provider.getOrCreate("flow:registration")) + assertEquals(1, clearedCallbacks) + } + + @Test + fun givenSessionIsCleared_whenOtherOwnersExist_thenOnlyThatSessionStoreIsRecreated() { + val session = WireSessionId("user", "wire.example") + val provider = ViewModelStoreProvider(parentStore = null) + val registry = WireViewModelStoreRegistry(provider) + val sessionOwner = WireViewModelOwner.Session(session) + val otherOwner = WireViewModelOwner.Flow("login") + val originalSession = registry.viewModelStoreFor(sessionOwner) + val originalFlow = registry.viewModelStoreFor(otherOwner) + + registry.clearSession(session) + + assertNotSame(originalSession, registry.viewModelStoreFor(sessionOwner)) + assertSame(originalFlow, registry.viewModelStoreFor(otherOwner)) + } + + @Test + fun givenApplicationOwnerIsCleared_whenItIsAcquiredAgain_thenFreshStoreIsCreated() { + val provider = ViewModelStoreProvider(parentStore = null) + val registry = WireViewModelStoreRegistry(provider) + val original = registry.viewModelStoreFor(WireViewModelOwner.Application) + + registry.clearApplication() + + assertNotSame(original, registry.viewModelStoreFor(WireViewModelOwner.Application)) + } + + @Test + fun givenLastFlowEntryIsPopped_whenExitAnimationIsRunning_thenClearedCallbackWaits() { + val flowOwner = WireViewModelOwner.Flow("login") + val provider = ViewModelStoreProvider(parentStore = null) + val cleared = mutableListOf() + val registry = WireViewModelStoreRegistry(provider, cleared::add) + registry.registerEntry(contentKey = "login", owners = setOf(flowOwner)) + val animationReference = provider.acquireToken(flowOwner.stableKey()) + + registry.onEntryPopped("login") + + assertEquals(emptyList(), cleared) + + animationReference.close() + + assertEquals(listOf(flowOwner.stableKey()), cleared) + } + + @Test + fun givenOwnerKinds_whenStableKeysAreCreated_thenIdentityIsDeterministic() { + val session = WireSessionId("user", "wire.example") + + assertEquals( + "entry:entry-id", + WireViewModelOwner.Entry(WireNavEntryId("entry-id")).stableKey(), + ) + assertEquals("flow:login", WireViewModelOwner.Flow("login").stableKey()) + assertEquals( + "session:user@wire.example", + WireViewModelOwner.Session(session).stableKey(), + ) + assertEquals("application", WireViewModelOwner.Application.stableKey()) + } + + @Test + fun givenAuthFlowAndTwoAccounts_whenFlowEndsAndOneSessionIsRemoved_thenOnlyDeclaredOwnersAreCleared() { + val firstSession = WireSessionId("first-user", "wire.example") + val secondSession = WireSessionId("second-user", "wire.example") + val flowOwner = WireViewModelOwner.Flow("add-second-account") + val firstSessionOwner = WireViewModelOwner.Session(firstSession) + val secondSessionOwner = WireViewModelOwner.Session(secondSession) + val provider = ViewModelStoreProvider(parentStore = null) + val cleared = mutableListOf() + val registry = WireViewModelStoreRegistry(provider, cleared::add) + + registry.registerEntry( + contentKey = "login", + owners = setOf(flowOwner, WireViewModelOwner.Application), + ) + registry.registerEntry( + contentKey = "too-many-devices", + owners = setOf(flowOwner, secondSessionOwner, WireViewModelOwner.Application), + ) + registry.registerEntry( + contentKey = "first-account-home", + owners = setOf(firstSessionOwner, WireViewModelOwner.Application), + ) + val firstStore = registry.viewModelStoreFor(firstSessionOwner) + val secondStore = registry.viewModelStoreFor(secondSessionOwner) + val applicationStore = registry.viewModelStoreFor(WireViewModelOwner.Application) + + registry.onEntryPopped("login") + assertEquals(emptyList(), cleared) + registry.onEntryPopped("too-many-devices") + assertEquals(listOf(flowOwner.stableKey()), cleared) + + registry.clearSession(secondSession) + + assertEquals( + listOf(flowOwner.stableKey(), secondSessionOwner.stableKey()), + cleared, + ) + assertSame(firstStore, registry.viewModelStoreFor(firstSessionOwner)) + assertNotSame(secondStore, registry.viewModelStoreFor(secondSessionOwner)) + assertSame(applicationStore, registry.viewModelStoreFor(WireViewModelOwner.Application)) + } +} diff --git a/core/navigation/stability/navigation-debug.stability b/core/navigation/stability/navigation-debug.stability index 0318979a388..bc376b376bd 100644 --- a/core/navigation/stability/navigation-debug.stability +++ b/core/navigation/stability/navigation-debug.stability @@ -5,31 +5,85 @@ // ./gradlew :navigation:stabilityDump @Composable -public fun com.wire.android.navigation.PreviewResultRecipient.onNavResult(listener: @[DisallowComposableCalls] kotlin.Function1, kotlin.Unit>): kotlin.Unit - skippable: true +public fun com.wire.android.navigation.navigation3.WireEntryEnvironment.Provide(route: com.wire.navigation.WireRoute, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit + skippable: false restartable: true params: - - listener: STABLE (function type) + - route: RUNTIME (requires runtime check) + - content: STABLE (composable function type) @Composable -public fun com.wire.android.navigation.PreviewResultRecipient.onNavResult(deliverResultOn: com.ramcosta.composedestinations.result.OpenResultRecipient.DeliverResultOn, listener: @[DisallowComposableCalls] kotlin.Function1, kotlin.Unit>): kotlin.Unit - skippable: true +public fun com.wire.android.navigation.navigation3.WireNav3Host(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, entryEnvironment: com.wire.android.navigation.navigation3.WireEntryEnvironment, entryProviderInstallers: kotlin.collections.List<@[ExtensionFunctionType] kotlin.Function1, kotlin.Unit>>, modifier: androidx.compose.ui.Modifier, onBack: kotlin.Function0, responsivePresentationPolicy: com.wire.android.navigation.navigation3.WireResponsivePresentationPolicy, isTablet: kotlin.Boolean, parentViewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, sharedViewModelStoreProvider: androidx.lifecycle.viewmodel.ViewModelStoreProvider, onViewModelOwnerAvailable: kotlin.Function2, onViewModelOwnerReleased: kotlin.Function2, onViewModelOwnerCleared: kotlin.Function1): kotlin.Unit + skippable: false restartable: true params: - - deliverResultOn: STABLE (class with no mutable properties) - - listener: STABLE (function type) + - runtime: STABLE (marked @Stable or @Immutable) + - entryEnvironment: RUNTIME (requires runtime check) + - entryProviderInstallers: RUNTIME (requires runtime check) + - modifier: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) + - responsivePresentationPolicy: STABLE (class with no mutable properties) + - isTablet: STABLE (primitive type) + - parentViewModelStoreOwner: RUNTIME (requires runtime check) + - sharedViewModelStoreProvider: UNSTABLE (has mutable properties or unstable members) + - onViewModelOwnerAvailable: STABLE (function type) + - onViewModelOwnerReleased: STABLE (function type) + - onViewModelOwnerCleared: STABLE (function type) @Composable -public fun com.wire.android.navigation.wrapper.TabletDialogWrapper.Wrap(screenContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit +internal fun com.wire.android.navigation.navigation3.WireTransitionInteractionGate(policy: com.wire.android.navigation.navigation3.WireTransitionInteractionPolicy, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true restartable: true params: - - screenContent: STABLE (composable function type) + - policy: STABLE (class with no mutable properties) + - content: STABLE (composable function type) @Composable -public fun com.wire.android.navigation.wrapper.WaitUntilTransitionEndsWrapper.Wrap(screenContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit - skippable: true +public fun com.wire.android.navigation.navigation3.rememberWireNavigation3Runtime(startRoute: com.wire.navigation.WireRoute, resultTypes: kotlin.collections.List>, canNavigate: kotlin.Function1, onNavigationChanged: kotlin.Function3<@[ParameterName(name = \): com.wire.android.navigation.navigation3.WireNavigation3Runtime + skippable: false + restartable: true + params: + - startRoute: RUNTIME (requires runtime check) + - resultTypes: RUNTIME (requires runtime check) + - canNavigate: STABLE (function type) + - onNavigationChanged: STABLE (function type) + +@Composable +public fun com.wire.android.navigation.navigation3.rememberWireSharedViewModelStoreOwner(key: kotlin.String, provider: androidx.lifecycle.viewmodel.ViewModelStoreProvider): androidx.lifecycle.ViewModelStoreOwner + skippable: false + restartable: true + params: + - key: STABLE (String is immutable) + - provider: UNSTABLE (has mutable properties or unstable members) + +@Composable +public fun com.wire.android.navigation.navigation3.rememberWireViewModelStoreNavEntryDecorator(storeProvider: androidx.lifecycle.viewmodel.ViewModelStoreProvider, onOwnerAvailable: kotlin.Function2, onOwnerReleased: kotlin.Function2, onOwnerCleared: kotlin.Function1): com.wire.android.navigation.navigation3.WireViewModelStoreNavEntryDecorator + skippable: false + restartable: true + params: + - storeProvider: UNSTABLE (has mutable properties or unstable members) + - onOwnerAvailable: STABLE (function type) + - onOwnerReleased: STABLE (function type) + - onOwnerCleared: STABLE (function type) + +@Composable +public fun com.wire.android.navigation.navigation3.rememberWireViewModelStoreNavEntryDecorator(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.navigation.navigation3.WireViewModelStoreNavEntryDecorator + skippable: false + restartable: true + params: + - viewModelStoreOwner: RUNTIME (requires runtime check) + +@Composable +public fun com.wire.android.navigation.navigation3.rememberWireViewModelStoreProvider(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): androidx.lifecycle.viewmodel.ViewModelStoreProvider + skippable: false + restartable: true + params: + - viewModelStoreOwner: RUNTIME (requires runtime check) + +@Composable +public fun com.wire.android.navigation.navigation3.wireViewModelStoreOwner(owner: com.wire.navigation.WireViewModelOwner): androidx.lifecycle.ViewModelStoreOwner + skippable: false restartable: true params: - - screenContent: STABLE (composable function type) + - owner: UNSTABLE (has mutable properties or unstable members) diff --git a/core/search/build.gradle.kts b/core/search/build.gradle.kts index afad4168aa1..59239eed680 100644 --- a/core/search/build.gradle.kts +++ b/core/search/build.gradle.kts @@ -25,17 +25,16 @@ dependencies { val composeBom = enforcedPlatform(libs.compose.bom) implementation(composeBom) + implementation(libs.compose.activity) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.material3) - implementation(libs.compose.navigation) implementation(libs.compose.ui.preview) implementation(libs.metrox.viewModelCompose) implementation(libs.visibilityModifiers) implementation(libs.androidx.paging3) implementation(libs.androidx.paging3Compose) - implementation(libs.resaca.core) implementation(libs.compose.edgetoedge.preview) implementation(libs.ktx.dateTime) debugImplementation(libs.compose.ui.tooling) diff --git a/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt b/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt index 3c270a9fa87..9b7a4551229 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt @@ -20,7 +20,7 @@ package com.wire.android.search import androidx.compose.runtime.Composable -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel import com.wire.android.search.apps.SearchAppsViewModel import com.wire.android.search.users.SearchUserViewModel import com.wire.kalium.logic.data.conversation.Conversation @@ -41,11 +41,11 @@ fun searchUserViewModel( conversationId: ConversationId? = null, onlyConnectedContacts: Boolean = false, ): SearchUserViewModel = - sessionKeyedAssistedMetroViewModel( - key = listOfNotNull( + wireAssistedMetroViewModel( + instanceKey = listOfNotNull( "search_user", if (onlyConnectedContacts) "only_connected_contacts" else null, - if (conversationId != null) "conversation_id_${conversationId.value}" else null + conversationId?.let { "conversation_id_${it.value}@${it.domain}" }, ).joinToString("_") ) { searchUserViewModel(conversationId, onlyConnectedContacts) @@ -53,8 +53,8 @@ fun searchUserViewModel( @Composable fun searchAppsViewModel(protocolInfo: Conversation.ProtocolInfo?): SearchAppsViewModel = - sessionKeyedAssistedMetroViewModel( - key = "search_apps_protocol_info_${protocolInfo?.name()}" + wireAssistedMetroViewModel( + instanceKey = "search_apps_protocol_info_${protocolInfo?.name()}" ) { searchAppsViewModel(protocolInfo) } diff --git a/core/ui-common/build.gradle.kts b/core/ui-common/build.gradle.kts index f60d0914c15..f366c61219e 100644 --- a/core/ui-common/build.gradle.kts +++ b/core/ui-common/build.gradle.kts @@ -33,10 +33,10 @@ dependencies { val composeBom = enforcedPlatform(libs.compose.bom) implementation(composeBom) + implementation(libs.compose.activity) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.material3) - implementation(libs.compose.navigation) implementation(libs.compose.ui.preview) implementation(libs.metrox.viewModelCompose) debugImplementation(libs.compose.ui.tooling) @@ -46,8 +46,6 @@ dependencies { implementation(libs.androidx.paging3) implementation(libs.androidx.paging3Compose) - // smaller view models - implementation(libs.resaca.core) implementation(libs.bundlizer.core) // Compose Preview diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonManualViewModelFactory.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonManualViewModelFactory.kt index 71f676a1376..a06e432bdb8 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonManualViewModelFactory.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonManualViewModelFactory.kt @@ -34,13 +34,13 @@ fun connectionActionButtonViewModel( args: ConnectionActionButtonArgs, ): ConnectionActionButtonViewModel = wireManualMetroViewModelScoped< - ConnectionActionButtonViewModelImpl, - ConnectionActionButtonViewModel, - ConnectionActionButtonArgs, - CoreUICommonManualViewModelFactory - >( + ConnectionActionButtonViewModelImpl, + ConnectionActionButtonViewModel, + ConnectionActionButtonArgs, + CoreUICommonManualViewModelFactory, + >( arguments = args, previewProvider = CoreUICommonViewModelScopedPreviews, - ) { _, arguments -> + ) { arguments -> connectionActionButtonViewModel(arguments) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonMetroViewModelBindings.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonMetroViewModelBindings.kt index 4c5fc2ee719..92075ec0baa 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonMetroViewModelBindings.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/CoreUICommonMetroViewModelBindings.kt @@ -31,7 +31,9 @@ object CoreUICommonMetroViewModelBindings { @Provides @IntoMap @ManualViewModelAssistedFactoryKey(CoreUICommonManualViewModelFactory::class) - internal fun coreUICommonManualViewModelFactory(factory: ConnectionActionButtonViewModelImpl.Factory): ManualViewModelAssistedFactory = + internal fun coreUiCommonManualViewModelFactory( + factory: ConnectionActionButtonViewModelImpl.Factory, + ): ManualViewModelAssistedFactory = object : CoreUICommonManualViewModelFactory { override fun connectionActionButtonViewModel(args: ConnectionActionButtonArgs): ConnectionActionButtonViewModelImpl = factory.create(args) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/connection/ConnectionActionButtonViewModel.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/connection/ConnectionActionButtonViewModel.kt index 3408525a0e8..cd541be5230 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/connection/ConnectionActionButtonViewModel.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/connection/ConnectionActionButtonViewModel.kt @@ -18,10 +18,6 @@ package com.wire.android.ui.common.connection -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject - import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -48,6 +44,9 @@ import com.wire.kalium.logic.feature.connection.UnblockUserResult import com.wire.kalium.logic.feature.connection.UnblockUserUseCase import com.wire.kalium.logic.feature.conversation.CreateConversationResult import com.wire.kalium.logic.feature.conversation.GetOrCreateOneToOneConversationUseCase +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/NavigationIconButton.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/NavigationIconButton.kt index 6d508653be3..d4a97d1c912 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/NavigationIconButton.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/NavigationIconButton.kt @@ -28,18 +28,23 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import com.wire.android.ui.common.R import com.wire.android.ui.common.dimensions @Composable fun NavigationIconButton(iconType: NavigationIconType, onClick: () -> Unit, modifier: Modifier = Modifier) { + val description = stringResource(iconType.contentDescription) IconButton( onClick = onClick, - modifier = modifier.size(dimensions().spacing48x) + modifier = modifier + .size(dimensions().spacing48x) + .semantics { contentDescription = description } ) { Icon( painter = painterResource(iconType.icon), - contentDescription = stringResource(iconType.contentDescription), + contentDescription = null, ) } } diff --git a/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md b/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md new file mode 100644 index 00000000000..959de5f6c3d --- /dev/null +++ b/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md @@ -0,0 +1,82 @@ +# 14. Migrate to Navigation 3 with KMP-ready navigation contracts + +Date: 2026-08-10 + +## Status + +Proposed + +## Context + +Wire currently uses Compose Destinations on top of Navigation Compose 2. Generated destinations, +route strings and `NavBackStackEntry` lookups are used not only for navigation, but also to select +Metro graphs, recover arguments and decide ViewModel ownership. This makes authentication, +multi-account switching, logout, process restoration and tablet presentation difficult to reason +about and has moved navigation recovery logic into `WireActivity`. + +Navigation state, a Metro graph instance and a ViewModel owner have related, but different, +lifecycles. Treating them as one mechanism can recreate a ViewModel against the wrong account or +make an existing screen depend on a temporarily unavailable `currentSession`. + +Compose Destinations is not the desired long-term navigation layer. Navigation 3 gives Wire an +application-owned, typed and saveable back stack, explicit entry decorators and a KMP-compatible +route model. + +## Decision + +We will replace Compose Destinations with Navigation 3 and Wire-owned navigation contracts. + +- Serializable routes and navigation commands live in `core:navigation-kmp`; concrete routes stay + with the feature that owns them. +- Features contribute typed `wireEntry` providers and expose semantic actions instead of receiving + generated navigators or `NavHostController`. +- Navigation 3 owns back-stack entries and their `ViewModelStoreOwner` lifecycle. +- Wire resolves the Metro graph independently from the typed route. A session route always carries + the session identity from which its dependencies must be resolved. +- Metro creates graphs and factories, while an application-owned registry controls the identity, + retention and disposal of graph instances. +- ViewModels receive route arguments explicitly through assisted factories. `SavedStateHandle` + remains for restorable UI state, not as the source of navigation identity. +- Results, deep links, transitions and phone/tablet presentation use typed Wire policies. +- `WireActivity` remains responsible for Android lifecycle and platform entry points; navigation, + session and intent decisions move to testable coordinators. + +For example, route identity, graph selection and ViewModel ownership are separate: + +```kotlin +@Serializable +data class ConversationRoute( + override val sessionId: WireSessionId, + val conversationId: ConversationId, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute + +wireEntry { route -> + ConversationScreen( + viewModel = conversationViewModel(route.toViewModelArgs()), + onBack = actions::back, + ) +} +``` + +`sessionId` selects the retained Metro session graph. Navigation 3 retains the owner identified by +the entry while that entry exists. A temporary `currentSession == null` therefore does not +reinterpret an existing route, replace its graph or recreate its ViewModel. + +The migration will be delivered incrementally: KMP contracts and runtime foundation, ownership +and Metro integration, vertical destination migrations, stabilization, and removal of Compose +Destinations compatibility code. + +## Consequences + +Navigation becomes typed, application-owned and testable. Existing entries keep deterministic +ViewModel owners and Metro graphs during account transitions, and KMP clients can reuse route, +command and reducer contracts. + +The migration temporarily increases the amount of adapter and test code. Wire must explicitly +implement and verify session transitions, results, deep links, responsive presentation and process +restoration; Navigation 3 does not provide Wire-specific policy by itself. + +The migration is complete only when one production navigation runtime remains, generated +navigation references and dependencies are removed, and unit, screenshot, acceptance, +critical-flow and multi-account tests plus internal playtests are green. diff --git a/features/cells/build.gradle.kts b/features/cells/build.gradle.kts index 2ec3a318b98..fc96279e247 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -3,8 +3,6 @@ plugins { id(libs.plugins.wire.kover.get().pluginId) id(BuildPlugins.kotlinParcelize) id(BuildPlugins.junit5) - alias(libs.plugins.ksp) - id(libs.plugins.wire.android.navigation.get().pluginId) id(libs.plugins.wire.compose.compiler.get().pluginId) alias(libs.plugins.compose.stability.analyzer) alias(libs.plugins.kotlin.serialization) @@ -15,8 +13,10 @@ dependencies { implementation("com.wire.kalium:kalium-logic") implementation("com.wire.kalium:kalium-cells") implementation(project(":core:di")) + implementation(project(":core:navigation")) implementation(project(":core:ui-common")) implementation(project(":core:media-player")) + implementation(libs.compose.activity) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) implementation(libs.androidx.browser) @@ -51,10 +51,3 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) testImplementation(testFixtures(project(":core:ui-common"))) } - -android { - ksp { - arg("compose-destinations.moduleName", "cells") - arg("compose-destinations.mode", "destinations") - } -} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavGraph.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavGraph.kt deleted file mode 100644 index 691658bac32..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavGraph.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.cells.navigation - -import com.ramcosta.composedestinations.annotation.ExternalModuleGraph -import com.ramcosta.composedestinations.annotation.NavGraph - -@NavGraph -annotation class CellsNavGraph( - val start: Boolean = false -) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt new file mode 100644 index 00000000000..998d289c7d5 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3.kt @@ -0,0 +1,234 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.feature.cells.navigation + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +sealed interface CellsRoute : SessionRoute + +@Serializable +data class CellsFilesArguments( + val conversationId: String? = null, + val screenTitle: String? = null, + val isRecycleBin: Boolean = false, + val breadcrumbs: List = emptyList(), + val parentFolderUuid: String? = null, + val isSearchByDefaultActive: Boolean = false, +) + +@Serializable +data class ConversationFilesRoute( + override val sessionId: WireSessionId, + val args: CellsFilesArguments, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/conversation_files_screen" } +} + +@Serializable +data class ConversationFilesSlideRoute( + override val sessionId: WireSessionId, + val args: CellsFilesArguments, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/conversation_files_with_slide_in_transition_screen" } +} + +@Serializable +data class RecycleBinRoute( + override val sessionId: WireSessionId, + val args: CellsFilesArguments, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/recycle_bin_screen" } +} + +@Serializable +data class CreateFolderRoute( + override val sessionId: WireSessionId, + val parentUuid: String?, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/create_folder_screen" } +} + +@Serializable +enum class CellsFileType { DOCUMENT, PRESENTATION, SPREADSHEET } + +@Serializable +data class CreateFileRoute( + override val sessionId: WireSessionId, + val parentUuid: String, + val fileType: CellsFileType, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/create_file_screen" } +} + +@Serializable +data class MoveToFolderRoute( + override val sessionId: WireSessionId, + val currentPath: String, + val nodeToMovePath: String, + val uuid: String, + val breadcrumbs: List = emptyList(), + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/move_to_folder_screen" } +} + +@Serializable +data class PublicLinkRoute( + override val sessionId: WireSessionId, + val assetId: String, + val fileName: String, + val publicLinkId: String?, + val isFolder: Boolean, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/public_link_screen" } +} + +@Serializable +data class PublicLinkExpirationRoute( + override val sessionId: WireSessionId, + val linkUuid: String, + val expiresAt: Long?, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/public_link_expiration_screen" } +} + +@Serializable +data class PublicLinkPasswordRoute( + override val sessionId: WireSessionId, + val linkUuid: String, + val passwordEnabled: Boolean, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/public_link_password_screen" } +} + +@Serializable +data class RenameNodeRoute( + override val sessionId: WireSessionId, + val uuid: String?, + val currentPath: String?, + val isFolder: Boolean?, + val nodeName: String?, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/rename_node_screen" } +} + +@Serializable +data class AddRemoveTagsRoute( + override val sessionId: WireSessionId, + val uuid: String, + val tags: List, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/add_remove_tags_screen" } +} + +@Serializable +data class VersionHistoryRoute( + override val sessionId: WireSessionId, + val uuid: String, + val fileName: String, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/version_history_screen" } +} + +@Serializable +data class CellImageViewerRoute( + override val sessionId: WireSessionId, + val localPath: String? = null, + val contentUrl: String? = null, + val previewUrl: String? = null, + val contentHash: String? = null, + val fileName: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/cell_image_viewer_screen" } +} + +@Serializable +data class VideoPlayerRoute( + override val sessionId: WireSessionId, + val localPath: String? = null, + val contentUrl: String? = null, + val fileName: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/video_player_screen" } +} + +@Serializable +data class AudioPlayerRoute( + override val sessionId: WireSessionId, + val localPath: String? = null, + val contentUrl: String? = null, + val fileName: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/audio_player_screen" } +} + +@Serializable +enum class CellsSearchType { SHARED_DRIVE, DRIVE } + +@Serializable +data class SearchRoute( + override val sessionId: WireSessionId, + val conversationId: String? = null, + val screenType: CellsSearchType = CellsSearchType.SHARED_DRIVE, + val parentRoute: String? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : CellsRoute { + override val routeId = ROUTE_ID + companion object { const val ROUTE_ID = "cells/search_screen" } +} + +@Serializable +data class CellsBooleanResult(val value: Boolean) + +val CellsBooleanResultContract = WireNavResultContract( + WireNavResultContractId("cells.boolean") +) + +@Serializable +data class PublicLinkExpirationNavigationResult( + val isExpirationSet: Boolean, + val expiresAt: Long?, +) + +val PublicLinkExpirationResultContract = + WireNavResultContract( + WireNavResultContractId("cells.public-link-expiration") + ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt new file mode 100644 index 00000000000..75fede95524 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Entries.kt @@ -0,0 +1,100 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.cells.navigation + +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry + +val CellsBooleanNavigation3ResultType: WireNavigation3ResultType = + WireNavigation3ResultType( + CellsBooleanResultContract, + CellsBooleanResult.serializer(), + ) + +val PublicLinkExpirationNavigation3ResultType: + WireNavigation3ResultType = + WireNavigation3ResultType( + PublicLinkExpirationResultContract, + PublicLinkExpirationNavigationResult.serializer(), + ) + +object CellsNavigation3Contribution { + const val ROUTE_REGISTRATION_COUNT: Int = 16 + + val resultTypes: List> = + listOf( + CellsBooleanNavigation3ResultType, + PublicLinkExpirationNavigation3ResultType, + ) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + onExitCells: () -> Unit, + ): List = + listOf(cellsNavigation3Entries(runtime, onExitCells)) +} + +internal fun cellsNavigation3Entries( + runtime: WireNavigation3Runtime, + onExitCells: () -> Unit, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + CellsNavigation3RouteScreen(it, runtime, onExitCells) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Mappers.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Mappers.kt new file mode 100644 index 00000000000..c85d32dcac0 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Mappers.kt @@ -0,0 +1,81 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +@file:Suppress("TooManyFunctions") // Central, mechanical boundary between route and screen models. + +package com.wire.android.feature.cells.navigation + +import com.wire.android.feature.cells.ui.CellFilesNavArgs +import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs +import com.wire.android.feature.cells.ui.create.file.FileType +import com.wire.android.feature.cells.ui.create.folder.CreateFolderScreenNavArgs +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs +import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderNavArgs +import com.wire.android.feature.cells.ui.publiclink.PublicLinkNavArgs +import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationResult +import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenNavArgs +import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordNavArgs +import com.wire.android.feature.cells.ui.rename.RenameNodeNavArgs +import com.wire.android.feature.cells.ui.search.DriveSearchScreenType +import com.wire.android.feature.cells.ui.search.SearchNavArgs +import com.wire.android.feature.cells.ui.tags.AddRemoveTagsNavArgs +import com.wire.android.feature.cells.ui.versioning.VersionHistoryNavArgs + +internal fun CellsFilesArguments.toScreenArgs() = CellFilesNavArgs( + conversationId = conversationId, + screenTitle = screenTitle, + isRecycleBin = isRecycleBin, + breadcrumbs = breadcrumbs.toTypedArray(), + parentFolderUuid = parentFolderUuid, + isSearchByDefaultActive = isSearchByDefaultActive, +) + +internal fun CellFilesNavArgs.toNavigation3() = CellsFilesArguments( + conversationId = conversationId, + screenTitle = screenTitle, + isRecycleBin = isRecycleBin ?: false, + breadcrumbs = breadcrumbs?.toList().orEmpty(), + parentFolderUuid = parentFolderUuid, + isSearchByDefaultActive = isSearchByDefaultActive ?: false, +) + +internal fun CreateFolderRoute.toScreenArgs() = CreateFolderScreenNavArgs(parentUuid) +internal fun CreateFileRoute.toScreenArgs() = CreateFileScreenNavArgs( + parentUuid, + when (fileType) { + CellsFileType.DOCUMENT -> FileType.DOCUMENT + CellsFileType.PRESENTATION -> FileType.PRESENTATION + CellsFileType.SPREADSHEET -> FileType.SPREADSHEET + }, +) +internal fun MoveToFolderRoute.toScreenArgs() = + MoveToFolderNavArgs(currentPath, nodeToMovePath, uuid, breadcrumbs.toTypedArray()) +internal fun PublicLinkRoute.toScreenArgs() = + PublicLinkNavArgs(assetId, fileName, publicLinkId, isFolder) +internal fun PublicLinkExpirationRoute.toScreenArgs() = + PublicLinkExpirationScreenNavArgs(linkUuid, expiresAt) +internal fun PublicLinkPasswordRoute.toScreenArgs() = + PublicLinkPasswordNavArgs(linkUuid, passwordEnabled) +internal fun RenameNodeRoute.toScreenArgs() = + RenameNodeNavArgs(uuid, currentPath, isFolder, nodeName) +internal fun AddRemoveTagsRoute.toScreenArgs() = + AddRemoveTagsNavArgs(uuid, ArrayList(tags)) +internal fun VersionHistoryRoute.toScreenArgs() = VersionHistoryNavArgs(uuid, fileName) +internal fun CellImageViewerRoute.toScreenArgs() = + CellImageViewerNavArgs(localPath, contentUrl, previewUrl, contentHash, fileName) +internal fun SearchRoute.toScreenArgs() = SearchNavArgs( + conversationId, + when (screenType) { + CellsSearchType.SHARED_DRIVE -> DriveSearchScreenType.SHARED_DRIVE + CellsSearchType.DRIVE -> DriveSearchScreenType.DRIVE + }, + parentRoute, +) + +internal fun PublicLinkExpirationResult.toNavigation3() = + PublicLinkExpirationNavigationResult(isExpirationSet, expiresAt) + +internal fun PublicLinkExpirationNavigationResult.toScreenResult() = + PublicLinkExpirationResult(isExpirationSet, expiresAt) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt new file mode 100644 index 00000000000..595f57b38ce --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt @@ -0,0 +1,419 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +@file:Suppress("LongMethod", "CyclomaticComplexMethod", "TooManyFunctions") + +package com.wire.android.feature.cells.navigation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.android.feature.cells.ui.CellsFilesNavigation +import com.wire.android.feature.cells.ui.ConversationFilesRouteScreen +import com.wire.android.feature.cells.ui.ConversationFilesSlideRouteScreen +import com.wire.android.feature.cells.ui.addRemoveTagsViewModel +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs +import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerRouteScreen +import com.wire.android.feature.cells.ui.cellAudioPlayerViewModel +import com.wire.android.feature.cells.ui.cellViewModel +import com.wire.android.feature.cells.ui.create.file.CreateFileRouteScreen +import com.wire.android.feature.cells.ui.create.file.FileType +import com.wire.android.feature.cells.ui.createFileViewModel +import com.wire.android.feature.cells.ui.create.folder.CreateFolderRouteScreen +import com.wire.android.feature.cells.ui.createFolderViewModel +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerScreenContent +import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScreen +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.moveToFolderViewModel +import com.wire.android.feature.cells.ui.publiclink.PublicLinkRouteScreen +import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData +import com.wire.android.feature.cells.ui.publicLinkExpirationViewModel +import com.wire.android.feature.cells.ui.publicLinkPasswordViewModel +import com.wire.android.feature.cells.ui.publicLinkViewModel +import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationRouteScreen +import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordRouteScreen +import com.wire.android.feature.cells.ui.recyclebin.RecycleBinRouteScreen +import com.wire.android.feature.cells.ui.rename.RenameNodeRouteScreen +import com.wire.android.feature.cells.ui.renameNodeViewModel +import com.wire.android.feature.cells.ui.search.SearchRouteScreen +import com.wire.android.feature.cells.ui.searchCellViewModel +import com.wire.android.feature.cells.ui.searchScreenViewModel +import com.wire.android.feature.cells.ui.tags.AddRemoveTagsRouteScreen +import com.wire.android.feature.cells.ui.versionHistoryViewModel +import com.wire.android.feature.cells.ui.versioning.VersionHistoryRouteScreen +import com.wire.android.mediaplayer.VideoPlayer +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import androidx.compose.ui.platform.LocalContext +import com.wire.navigation.WireBackStackMode +import com.wire.navigation.WireNavResult +import com.wire.navigation.WireNavResultRequestId +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireSessionId + +/** Feature-owned Navigation 3 renderer used by the app catalog. */ +@Composable +internal fun CellsNavigation3RouteScreen( + route: CellsRoute, + runtime: WireNavigation3Runtime, + onExitCells: () -> Unit, +) { + var booleanRequestIdValue by rememberSaveable(route.entryId.value, "boolean-result") { + mutableStateOf(null) + } + var expirationRequestIdValue by rememberSaveable(route.entryId.value, "expiration-result") { + mutableStateOf(null) + } + val navigateBack: () -> Unit = { + if (!runtime.navigator.goBack()) onExitCells() + } + val filesNavigation = remember(route.entryId, runtime, onExitCells) { + Navigation3CellsFilesNavigation(runtime, route.sessionId, onExitCells) + } + + when (route) { + is ConversationFilesRoute -> AnimatedVisibility(visible = true) { + ConversationFilesRouteScreen( + navigation = filesNavigation, + animatedVisibilityScope = this, + viewModel = cellViewModel(route.args.toScreenArgs()), + ) + } + is ConversationFilesSlideRoute -> AnimatedVisibility(visible = true) { + ConversationFilesSlideRouteScreen( + navigation = filesNavigation, + cellFilesNavArgs = route.args.toScreenArgs(), + animatedVisibilityScope = this, + viewModel = cellViewModel(route.args.toScreenArgs()), + ) + } + is RecycleBinRoute -> RecycleBinRouteScreen( + navigation = filesNavigation, + cellViewModel = cellViewModel(route.args.toScreenArgs()), + ) + is CreateFolderRoute -> CreateFolderRouteScreen( + onNavigateBack = navigateBack, + onCreated = { + completeBooleanResult(runtime, true) + }, + createFolderViewModel = createFolderViewModel(route.toScreenArgs()), + ) + is CreateFileRoute -> CreateFileRouteScreen( + onNavigateBack = navigateBack, + onCreated = { + completeBooleanResult(runtime, true) + }, + createFileViewModel = createFileViewModel(route.toScreenArgs()), + ) + is MoveToFolderRoute -> { + val viewModel = moveToFolderViewModel(route.toScreenArgs()) + consumeNavigation3Result( + runtime = runtime, + requestIdValue = booleanRequestIdValue, + resultType = CellsBooleanNavigation3ResultType, + onConsumed = { booleanRequestIdValue = null }, + onResult = { if (it.value) viewModel.loadFolders() }, + ) + MoveToFolderRouteScreen( + onNavigateBack = navigateBack, + onCloseFlow = { + popConsecutive(runtime, MoveToFolderRoute.ROUTE_ID, onExitCells) + }, + onNavigateBackSteps = { steps -> + repeat(steps) { + if (runtime.navigator.currentRoute?.routeId != MoveToFolderRoute.ROUTE_ID) return@repeat + runtime.navigator.goBack() + } + }, + onOpenCreateFolder = { uuid -> + runtime.navigateForResult( + CreateFolderRoute(route.sessionId, uuid), + CellsBooleanNavigation3ResultType, + ).also { booleanRequestIdValue = it?.value } + }, + onOpenFolder = { path, nodePath, nodeUuid, breadcrumbs -> + runtime.navigator.navigate( + WireNavigationCommand( + MoveToFolderRoute( + route.sessionId, + path, + nodePath, + nodeUuid, + breadcrumbs, + ), + launchSingleTop = false, + ) + ) + }, + moveToFolderViewModel = viewModel, + ) + } + is PublicLinkRoute -> { + val viewModel = publicLinkViewModel(route.toScreenArgs()) + consumeNavigation3Result( + runtime = runtime, + requestIdValue = booleanRequestIdValue, + resultType = CellsBooleanNavigation3ResultType, + onConsumed = { booleanRequestIdValue = null }, + onResult = { viewModel.onPasswordUpdate(it.value) }, + ) + consumeNavigation3Result( + runtime = runtime, + requestIdValue = expirationRequestIdValue, + resultType = PublicLinkExpirationNavigation3ResultType, + onConsumed = { expirationRequestIdValue = null }, + onResult = { viewModel.onExpirationUpdate(it.toScreenResult()) }, + ) + PublicLinkRouteScreen( + onNavigateBack = navigateBack, + onOpenPasswordSettings = { linkUuid, passwordEnabled -> + runtime.navigateForResult( + PublicLinkPasswordRoute(route.sessionId, linkUuid, passwordEnabled), + CellsBooleanNavigation3ResultType, + ).also { booleanRequestIdValue = it?.value } + }, + onOpenExpirationSettings = { linkUuid, expiresAt -> + runtime.navigateForResult( + PublicLinkExpirationRoute(route.sessionId, linkUuid, expiresAt), + PublicLinkExpirationNavigation3ResultType, + ).also { expirationRequestIdValue = it?.value } + }, + viewModel = viewModel, + ) + } + is PublicLinkExpirationRoute -> PublicLinkExpirationRouteScreen( + onResult = { result -> + if (!runtime.completeCurrentAndPop( + PublicLinkExpirationNavigation3ResultType, + WireNavResult.Value(result.toNavigation3()), + ) + ) { + runtime.navigator.goBack() + } + }, + viewModel = publicLinkExpirationViewModel(route.toScreenArgs()), + ) + is PublicLinkPasswordRoute -> PublicLinkPasswordRouteScreen( + onResult = { completeBooleanResult(runtime, it) }, + viewModel = publicLinkPasswordViewModel(route.toScreenArgs()), + ) + is RenameNodeRoute -> RenameNodeRouteScreen( + onNavigateBack = navigateBack, + renameNodeViewModel = renameNodeViewModel(route.toScreenArgs()), + ) + is AddRemoveTagsRoute -> AddRemoveTagsRouteScreen( + onNavigateBack = navigateBack, + addRemoveTagsViewModel = addRemoveTagsViewModel(route.toScreenArgs()), + ) + is VersionHistoryRoute -> VersionHistoryRouteScreen( + onNavigateBack = navigateBack, + versionHistoryViewModel = versionHistoryViewModel(route.toScreenArgs()), + ) + is CellImageViewerRoute -> CellImageViewerScreenContent( + localPath = route.localPath, + contentUrl = route.contentUrl, + previewUrl = route.previewUrl, + contentHash = route.contentHash, + fileName = route.fileName, + onNavigateBack = navigateBack, + ) + is VideoPlayerRoute -> VideoPlayer( + localPath = route.localPath, + contentUrl = route.contentUrl, + fileName = route.fileName, + onNavigateBack = navigateBack, + ) + is AudioPlayerRoute -> CellAudioPlayerRouteScreen( + onNavigateBack = navigateBack, + viewModel = cellAudioPlayerViewModel( + LocalContext.current, + AudioPlayerNavArgs(route.localPath, route.contentUrl, route.fileName), + ), + ) + is SearchRoute -> AnimatedVisibility(visible = true) { + SearchRouteScreen( + navigation = filesNavigation, + animatedVisibilityScope = this, + cellViewModel = searchCellViewModel(route.toScreenArgs()), + searchScreenViewModel = searchScreenViewModel(route.toScreenArgs()), + ) + } + } +} + +private class Navigation3CellsFilesNavigation( + private val runtime: WireNavigation3Runtime, + private val sessionId: WireSessionId, + private val onExit: () -> Unit, +) : CellsFilesNavigation { + override fun back() { + if (!runtime.navigator.goBack()) onExit() + } + + override fun createFolder(parentUuid: String?) { + runtime.navigator.navigate(WireNavigationCommand(CreateFolderRoute(sessionId, parentUuid))) + } + + override fun createFile(parentUuid: String, fileType: FileType) { + runtime.navigator.navigate( + WireNavigationCommand( + CreateFileRoute( + sessionId, + parentUuid, + when (fileType) { + FileType.DOCUMENT -> CellsFileType.DOCUMENT + FileType.PRESENTATION -> CellsFileType.PRESENTATION + FileType.SPREADSHEET -> CellsFileType.SPREADSHEET + }, + ) + ) + ) + } + + override fun recycleBin(args: com.wire.android.feature.cells.ui.CellFilesNavArgs, popConsecutive: Boolean) { + runtime.navigator.navigate( + WireNavigationCommand( + RecycleBinRoute(sessionId, args.toNavigation3()), + backStackMode = if (popConsecutive) { + WireBackStackMode.POP_CONSECUTIVE_SAME_ROUTES + } else { + WireBackStackMode.NONE + }, + ) + ) + } + + override fun search(conversationId: String) { + runtime.navigator.navigate(WireNavigationCommand(SearchRoute(sessionId, conversationId))) + } + + override fun folder(args: com.wire.android.feature.cells.ui.CellFilesNavArgs) { + runtime.navigator.navigate( + WireNavigationCommand( + ConversationFilesSlideRoute(sessionId, args.toNavigation3()), + launchSingleTop = false, + ) + ) + } + + override fun publicLink(data: PublicLinkScreenData) { + runtime.navigator.navigate( + WireNavigationCommand( + PublicLinkRoute(sessionId, data.assetId, data.fileName, data.linkId, data.isFolder) + ) + ) + } + + override fun move(currentPath: String, nodePath: String, uuid: String) { + runtime.navigator.navigate( + WireNavigationCommand(MoveToFolderRoute(sessionId, currentPath, nodePath, uuid)) + ) + } + + override fun rename(node: CellNodeUi) { + runtime.navigator.navigate( + WireNavigationCommand( + RenameNodeRoute( + sessionId, + node.uuid, + node.remotePath, + node is CellNodeUi.Folder, + node.name, + ) + ) + ) + } + + override fun tags(node: CellNodeUi) { + runtime.navigator.navigate(WireNavigationCommand(AddRemoveTagsRoute(sessionId, node.uuid, node.tags))) + } + + override fun versionHistory(uuid: String, fileName: String) { + runtime.navigator.navigate(WireNavigationCommand(VersionHistoryRoute(sessionId, uuid, fileName))) + } + + override fun image(file: CellNodeUi.File) { + runtime.navigator.navigate( + WireNavigationCommand( + CellImageViewerRoute( + sessionId, + file.localPath, + file.contentUrl, + file.previewUrl, + file.contentHash, + file.name, + ) + ) + ) + } + + override fun video(file: CellNodeUi.File) { + runtime.navigator.navigate( + WireNavigationCommand( + VideoPlayerRoute(sessionId, file.localPath, file.contentUrl, file.name) + ) + ) + } + + override fun audio(file: CellNodeUi.File) { + runtime.navigator.navigate( + WireNavigationCommand( + AudioPlayerRoute(sessionId, file.localPath, file.contentUrl, file.name) + ) + ) + } +} + +private fun completeBooleanResult(runtime: WireNavigation3Runtime, value: Boolean) { + if (!runtime.completeCurrentAndPop( + CellsBooleanNavigation3ResultType, + WireNavResult.Value(CellsBooleanResult(value)), + ) + ) { + runtime.navigator.goBack() + } +} + +@Composable +private fun consumeNavigation3Result( + runtime: WireNavigation3Runtime, + requestIdValue: String?, + resultType: WireNavigation3ResultType, + onConsumed: () -> Unit, + onResult: (T) -> Unit, +) { + LaunchedEffect(requestIdValue, runtime.navigator.currentRoute?.entryId) { + val id = requestIdValue?.let(::WireNavResultRequestId) ?: return@LaunchedEffect + when (val result = runtime.consumeResult(id, resultType)) { + is WireNavResult.Value -> onResult(result.value) + WireNavResult.Canceled -> Unit + null -> return@LaunchedEffect + } + onConsumed() + } +} + +private fun popConsecutive( + runtime: WireNavigation3Runtime, + routeId: String, + onExit: () -> Unit, +) { + while (runtime.navigator.currentRoute?.routeId == routeId) { + if (!runtime.navigator.goBack()) { + onExit() + return + } + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt new file mode 100644 index 00000000000..15f53a23095 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesNavigationActions.kt @@ -0,0 +1,29 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.cells.ui + +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData + +/** + * Semantic navigation boundary for the Global Cells root. + * + * It deliberately uses feature models instead of generated destinations, which lets the Home + * owner keep [CellViewModel] alive while the destination implementation migrates independently. + */ +data class AllFilesNavigationActions( + val openSearch: () -> Unit, + val showPublicLink: (PublicLinkScreenData) -> Unit, + val showAddRemoveTags: (CellNodeUi) -> Unit, + val showImageViewer: (CellNodeUi.File) -> Unit, + val showVideoPlayer: (CellNodeUi.File) -> Unit, + val showAudioPlayer: (CellNodeUi.File) -> Unit, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index a004cd208e3..5a6c2f7a46c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -20,54 +20,29 @@ package com.wire.android.feature.cells.ui import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.paging.compose.collectAsLazyPagingItems -import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner -import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs -import com.wire.android.feature.cells.ui.search.DriveSearchScreenType -import com.wire.android.feature.cells.ui.search.sort.SortRowWithMenu -import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.WireNavigator import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.search.SearchTopBar -/** - * Show files in all conversations with a search bar. - */ @Suppress("CyclomaticComplexMethod") @Composable fun AllFilesScreen( - navigator: WireNavigator, + navigationActions: AllFilesNavigationActions, modifier: Modifier = Modifier, - viewModel: CellViewModel = cellViewModel(), + viewModel: CellViewModel = cellViewModel(CellFilesNavArgs()), ) { - val pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems() val isOnlineState by viewModel.isOnline.collectAsState() // When offline files are disabled, never enter offline mode so all offline UI stays hidden. val isOnline = isOnlineState || !viewModel.offlineFilesEnabled - val sortingCriteria by viewModel.sortingCriteria.collectAsState() - - val lazyListState = rememberLazyListState() - LaunchedEffect(sortingCriteria) { - lazyListState.animateScrollToItem(0) - } WireScaffold( modifier = modifier, @@ -75,29 +50,13 @@ fun AllFilesScreen( Column { AnimatedContent(isOnline) { if (it) { - Column { - SearchTopBar( - modifier = Modifier, - isSearchActive = false, - searchBarHint = stringResource(R.string.search_label), - searchQueryTextState = rememberTextFieldState(), - onTap = { - navigator.navigate( - NavigationCommand( - SearchScreenDestination( - screenType = DriveSearchScreenType.DRIVE, - ) - ) - ) - }, - ) - SortRowWithMenu( - sortingCriteria = sortingCriteria, - screenType = DriveSearchScreenType.DRIVE, - onSortByClicked = { viewModel.setSortBy(it) }, - onOrderClicked = { viewModel.setSorting(it) }, - ) - } + SearchTopBar( + modifier = Modifier, + isSearchActive = false, + searchBarHint = stringResource(R.string.search_label), + searchQueryTextState = rememberTextFieldState(), + onTap = navigationActions.openSearch, + ) } else { OfflineBanner() } @@ -107,7 +66,6 @@ fun AllFilesScreen( ) { innerPadding -> CellScreenContent( modifier = Modifier.padding(innerPadding), - lazyListState = lazyListState, actionsFlow = viewModel.actions, pagingListItems = pagingListItems, sendIntent = { viewModel.sendIntent(it) }, @@ -119,70 +77,15 @@ fun AllFilesScreen( isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRecycleBin = viewModel.isRecycleBin(), isSearchResult = false, - showPublicLinkScreen = { publicLinkScreenData -> - navigator.navigate( - NavigationCommand( - PublicLinkScreenDestination( - assetId = publicLinkScreenData.assetId, - fileName = publicLinkScreenData.fileName, - publicLinkId = publicLinkScreenData.linkId, - isFolder = publicLinkScreenData.isFolder - ) - ) - ) - }, + showPublicLinkScreen = navigationActions.showPublicLink, showMoveToFolderScreen = { _, _, _ -> }, showRenameScreen = {}, - showAddRemoveTagsScreen = { node -> - navigator.navigate( - NavigationCommand( - AddRemoveTagsScreenDestination(node.uuid, node.tags.toCollection(ArrayList())) - ) - ) - }, + showAddRemoveTagsScreen = navigationActions.showAddRemoveTags, isRefreshing = viewModel.isPullToRefresh.collectAsState(), onRefresh = { viewModel.onPullToRefresh() }, - showImageViewer = { file -> - navigator.navigate( - NavigationCommand( - CellImageViewerScreenDestination( - CellImageViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - previewUrl = file.previewUrl, - contentHash = file.contentHash, - fileName = file.name, - ) - ) - ) - ) - }, - showVideoViewer = { file -> - navigator.navigate( - NavigationCommand( - VideoPlayerScreenDestination( - VideoViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, - showAudioPlayer = { file -> - navigator.navigate( - NavigationCommand( - CellAudioPlayerScreenDestination( - AudioPlayerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, + showImageViewer = navigationActions.showImageViewer, + showVideoViewer = navigationActions.showVideoPlayer, + showAudioPlayer = navigationActions.showAudioPlayer, fileReadyFlow = viewModel.fileReadyFlow, ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 1d4babd66a1..b8d5ab18a8c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.LoadState import androidx.paging.LoadStates @@ -25,8 +24,6 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.edit.OnlineEditor @@ -96,7 +93,8 @@ import kotlin.time.Duration.Companion.milliseconds @Suppress("TooManyFunctions", "LongParameterList") class CellViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: CellFilesNavArgs, + @Assisted private val searchNavArgs: SearchNavArgs?, private val getCellFilesPaged: GetPaginatedFilesFlowUseCase, private val deleteCellAsset: DeleteCellAssetUseCase, private val restoreNodeFromRecycleBinUseCase: RestoreNodeFromRecycleBinUseCase, @@ -122,19 +120,7 @@ class CellViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CellViewModel - } - - private val searchNavArgs: SearchNavArgs? = try { - SearchScreenDestination.argsFrom(savedStateHandle) - } catch (_: RuntimeException) { - // Not coming from Search screen, ignore - null - } - private val navArgs: CellFilesNavArgs = try { - ConversationFilesScreenDestination.argsFrom(savedStateHandle) - } catch (_: RuntimeException) { - searchNavArgs?.toCellFilesNavArgs() ?: CellFilesNavArgs() + fun create(navArgs: CellFilesNavArgs, searchNavArgs: SearchNavArgs?): CellViewModel } // Show menu with actions for the selected file. @@ -717,7 +703,7 @@ data class MenuOptions( val actions: List ) -private fun SearchNavArgs.toCellFilesNavArgs(): CellFilesNavArgs = +internal fun SearchNavArgs.toCellFilesNavArgs(): CellFilesNavArgs = CellFilesNavArgs(conversationId = conversationId) private const val RESTORE_DELAY_MS = 300L diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt new file mode 100644 index 00000000000..46eb260b158 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsFilesNavigation.kt @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + */ + +package com.wire.android.feature.cells.ui + +import com.wire.android.feature.cells.ui.create.file.FileType +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData + +@Suppress("TooManyFunctions") +internal interface CellsFilesNavigation { + fun back() + fun createFolder(parentUuid: String?) + fun createFile(parentUuid: String, fileType: FileType) + fun recycleBin(args: CellFilesNavArgs, popConsecutive: Boolean = false) + fun search(conversationId: String) + fun folder(args: CellFilesNavArgs) + fun publicLink(data: PublicLinkScreenData) + fun move(currentPath: String, nodePath: String, uuid: String) + fun rename(node: CellNodeUi) + fun tags(node: CellNodeUi) + fun versionHistory(uuid: String, fileName: String) + fun image(file: CellNodeUi.File) + fun video(file: CellNodeUi.File) + fun audio(file: CellNodeUi.File) +} + +@Suppress("TooManyFunctions") +internal object NoOpCellsFilesNavigation : CellsFilesNavigation { + override fun back() = Unit + override fun createFolder(parentUuid: String?) = Unit + override fun createFile(parentUuid: String, fileType: FileType) = Unit + override fun recycleBin(args: CellFilesNavArgs, popConsecutive: Boolean) = Unit + override fun search(conversationId: String) = Unit + override fun folder(args: CellFilesNavArgs) = Unit + override fun publicLink(data: PublicLinkScreenData) = Unit + override fun move(currentPath: String, nodePath: String, uuid: String) = Unit + override fun rename(node: CellNodeUi) = Unit + override fun tags(node: CellNodeUi) = Unit + override fun versionHistory(uuid: String, fileName: String) = Unit + override fun image(file: CellNodeUi.File) = Unit + override fun video(file: CellNodeUi.File) = Unit + override fun audio(file: CellNodeUi.File) = Unit +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt index 1156a0fa65b..a0fcdb43333 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt @@ -20,114 +20,92 @@ package com.wire.android.feature.cells.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.createSavedStateHandle -import androidx.lifecycle.viewmodel.CreationExtras import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel +import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel +import com.wire.android.feature.cells.ui.create.folder.CreateFolderScreenNavArgs +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel +import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderNavArgs import com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel +import com.wire.android.feature.cells.ui.publiclink.PublicLinkNavArgs +import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenNavArgs import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel +import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordNavArgs +import com.wire.android.feature.cells.ui.rename.RenameNodeNavArgs import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel +import com.wire.android.feature.cells.ui.search.SearchNavArgs +import com.wire.android.feature.cells.ui.tags.AddRemoveTagsNavArgs import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel +import com.wire.android.feature.cells.ui.versioning.VersionHistoryNavArgs import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provides -import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory -import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey + +internal interface CellsManualViewModelFactory : ManualViewModelAssistedFactory { + fun cell(navArgs: CellFilesNavArgs): CellViewModel + fun searchCell(navArgs: SearchNavArgs): CellViewModel + fun createFile(navArgs: CreateFileScreenNavArgs): CreateFileViewModel + fun createFolder(navArgs: CreateFolderScreenNavArgs): CreateFolderViewModel + fun moveToFolder(navArgs: MoveToFolderNavArgs): MoveToFolderViewModel + fun publicLink(navArgs: PublicLinkNavArgs): PublicLinkViewModel + fun publicLinkExpiration(navArgs: PublicLinkExpirationScreenNavArgs): PublicLinkExpirationScreenViewModel + fun publicLinkPassword(navArgs: PublicLinkPasswordNavArgs): PublicLinkPasswordScreenViewModel + fun renameNode(navArgs: RenameNodeNavArgs): RenameNodeViewModel + fun search(navArgs: SearchNavArgs): SearchScreenViewModel + fun addRemoveTags(navArgs: AddRemoveTagsNavArgs): AddRemoveTagsViewModel + fun versionHistory(navArgs: VersionHistoryNavArgs): VersionHistoryViewModel + fun imageViewer(navArgs: CellImageViewerNavArgs): CellImageViewerViewModel + fun audioPlayer(context: android.content.Context, navArgs: AudioPlayerNavArgs): AudioPlayerViewModel +} @BindingContainer object CellsMetroViewModelBindings { @Provides @IntoMap - @ViewModelAssistedFactoryKey(CellViewModel::class) - fun cellViewModel(factory: CellViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateFileViewModel::class) - fun createFileViewModel(factory: CreateFileViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CreateFolderViewModel::class) - fun createFolderViewModel(factory: CreateFolderViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(MoveToFolderViewModel::class) - fun moveToFolderViewModel(factory: MoveToFolderViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(PublicLinkViewModel::class) - fun publicLinkViewModel(factory: PublicLinkViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(PublicLinkExpirationScreenViewModel::class) - internal fun publicLinkExpirationScreenViewModel( - factory: PublicLinkExpirationScreenViewModel.Factory, - ): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(PublicLinkPasswordScreenViewModel::class) - internal fun publicLinkPasswordScreenViewModel( - factory: PublicLinkPasswordScreenViewModel.Factory, - ): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(RenameNodeViewModel::class) - fun renameNodeViewModel(factory: RenameNodeViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(SearchScreenViewModel::class) - fun searchScreenViewModel(factory: SearchScreenViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(AddRemoveTagsViewModel::class) - fun addRemoveTagsViewModel(factory: AddRemoveTagsViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(VersionHistoryViewModel::class) - fun versionHistoryViewModel(factory: VersionHistoryViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CellImageViewerViewModel::class) - fun imageViewerViewModel(factory: CellImageViewerViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(AudioPlayerViewModel::class) - fun audioPlayerViewModel(factory: AudioPlayerViewModel.Factory): ViewModelAssistedFactory = - savedStateViewModel { factory.create(it.createSavedStateHandle()) } - - private fun savedStateViewModel(create: (CreationExtras) -> ViewModel): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = create(extras) + @ManualViewModelAssistedFactoryKey(CellsManualViewModelFactory::class) + @Suppress("LongParameterList") + internal fun manualViewModelFactory( + cellFactory: CellViewModel.Factory, + createFileFactory: CreateFileViewModel.Factory, + createFolderFactory: CreateFolderViewModel.Factory, + moveToFolderFactory: MoveToFolderViewModel.Factory, + publicLinkFactory: PublicLinkViewModel.Factory, + publicLinkExpirationFactory: PublicLinkExpirationScreenViewModel.Factory, + publicLinkPasswordFactory: PublicLinkPasswordScreenViewModel.Factory, + renameNodeFactory: RenameNodeViewModel.Factory, + searchFactory: SearchScreenViewModel.Factory, + addRemoveTagsFactory: AddRemoveTagsViewModel.Factory, + versionHistoryFactory: VersionHistoryViewModel.Factory, + imageViewerFactory: CellImageViewerViewModel.Factory, + audioPlayerFactory: AudioPlayerViewModel.Factory, + ): ManualViewModelAssistedFactory = + object : CellsManualViewModelFactory { + override fun cell(navArgs: CellFilesNavArgs) = cellFactory.create(navArgs, null) + override fun searchCell(navArgs: SearchNavArgs) = cellFactory.create(navArgs.toCellFilesNavArgs(), navArgs) + override fun createFile(navArgs: CreateFileScreenNavArgs) = createFileFactory.create(navArgs) + override fun createFolder(navArgs: CreateFolderScreenNavArgs) = createFolderFactory.create(navArgs) + override fun moveToFolder(navArgs: MoveToFolderNavArgs) = moveToFolderFactory.create(navArgs) + override fun publicLink(navArgs: PublicLinkNavArgs) = publicLinkFactory.create(navArgs) + override fun publicLinkExpiration(navArgs: PublicLinkExpirationScreenNavArgs) = + publicLinkExpirationFactory.create(navArgs) + override fun publicLinkPassword(navArgs: PublicLinkPasswordNavArgs) = + publicLinkPasswordFactory.create(navArgs) + override fun renameNode(navArgs: RenameNodeNavArgs) = renameNodeFactory.create(navArgs) + override fun search(navArgs: SearchNavArgs) = searchFactory.create(navArgs) + override fun addRemoveTags(navArgs: AddRemoveTagsNavArgs) = addRemoveTagsFactory.create(navArgs) + override fun versionHistory(navArgs: VersionHistoryNavArgs) = versionHistoryFactory.create(navArgs) + override fun imageViewer(navArgs: CellImageViewerNavArgs) = imageViewerFactory.create(navArgs) + override fun audioPlayer(context: android.content.Context, navArgs: AudioPlayerNavArgs) = + audioPlayerFactory.create(context, navArgs.localPath, navArgs.contentUrl, navArgs.fileName) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt index 1965873683b..0f6f9076d4d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt @@ -21,76 +21,101 @@ package com.wire.android.feature.cells.ui import androidx.compose.runtime.Composable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.di.metro.wireAssistedMetroViewModel import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs +import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel +import com.wire.android.feature.cells.ui.create.folder.CreateFolderScreenNavArgs +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel +import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderNavArgs import com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel +import com.wire.android.feature.cells.ui.publiclink.PublicLinkNavArgs +import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenNavArgs import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel +import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordNavArgs +import com.wire.android.feature.cells.ui.rename.RenameNodeNavArgs import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel +import com.wire.android.feature.cells.ui.search.SearchNavArgs +import com.wire.android.feature.cells.ui.tags.AddRemoveTagsNavArgs import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel +import com.wire.android.feature.cells.ui.versioning.VersionHistoryNavArgs @Composable -inline fun cellsViewModel( - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, - key: String? = null, -): VM where VM : ViewModel = - sessionKeyedMetroViewModel( - viewModelStoreOwner = viewModelStoreOwner, - key = key, - ) +fun cellViewModel(navArgs: CellFilesNavArgs): CellViewModel = + wireAssistedMetroViewModel { + cell(navArgs) + } @Composable -fun cellViewModel( - viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { - "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" - }, -): CellViewModel = cellsViewModel(viewModelStoreOwner = viewModelStoreOwner) +internal fun searchCellViewModel(navArgs: SearchNavArgs): CellViewModel = + wireAssistedMetroViewModel( + instanceKey = "search-cell", + ) { + searchCell(navArgs) + } @Composable -fun createFileViewModel(): CreateFileViewModel = cellsViewModel() +internal fun createFileViewModel(navArgs: CreateFileScreenNavArgs): CreateFileViewModel = + wireAssistedMetroViewModel { createFile(navArgs) } @Composable -fun createFolderViewModel(): CreateFolderViewModel = cellsViewModel() +internal fun createFolderViewModel(navArgs: CreateFolderScreenNavArgs): CreateFolderViewModel = + wireAssistedMetroViewModel { createFolder(navArgs) } @Composable -fun moveToFolderViewModel(): MoveToFolderViewModel = cellsViewModel() +internal fun moveToFolderViewModel(navArgs: MoveToFolderNavArgs): MoveToFolderViewModel = + wireAssistedMetroViewModel { moveToFolder(navArgs) } @Composable -fun publicLinkViewModel(): PublicLinkViewModel = cellsViewModel() +internal fun publicLinkViewModel(navArgs: PublicLinkNavArgs): PublicLinkViewModel = + wireAssistedMetroViewModel { publicLink(navArgs) } @Composable -internal fun publicLinkExpirationScreenViewModel(): PublicLinkExpirationScreenViewModel = - cellsViewModel() +internal fun publicLinkExpirationViewModel( + navArgs: PublicLinkExpirationScreenNavArgs, +): PublicLinkExpirationScreenViewModel = + wireAssistedMetroViewModel { + publicLinkExpiration(navArgs) + } @Composable -internal fun publicLinkPasswordScreenViewModel(): PublicLinkPasswordScreenViewModel = - cellsViewModel() +internal fun publicLinkPasswordViewModel(navArgs: PublicLinkPasswordNavArgs): PublicLinkPasswordScreenViewModel = + wireAssistedMetroViewModel { + publicLinkPassword(navArgs) + } @Composable -fun renameNodeViewModel(): RenameNodeViewModel = cellsViewModel() +internal fun renameNodeViewModel(navArgs: RenameNodeNavArgs): RenameNodeViewModel = + wireAssistedMetroViewModel { renameNode(navArgs) } @Composable -fun searchScreenViewModel(): SearchScreenViewModel = cellsViewModel() +internal fun searchScreenViewModel(navArgs: SearchNavArgs): SearchScreenViewModel = + wireAssistedMetroViewModel { search(navArgs) } @Composable -fun addRemoveTagsViewModel(): AddRemoveTagsViewModel = cellsViewModel() +internal fun addRemoveTagsViewModel(navArgs: AddRemoveTagsNavArgs): AddRemoveTagsViewModel = + wireAssistedMetroViewModel { addRemoveTags(navArgs) } @Composable -fun versionHistoryViewModel(): VersionHistoryViewModel = cellsViewModel() +internal fun versionHistoryViewModel(navArgs: VersionHistoryNavArgs): VersionHistoryViewModel = + wireAssistedMetroViewModel { versionHistory(navArgs) } @Composable -fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() +internal fun cellImageViewerViewModel(navArgs: CellImageViewerNavArgs): CellImageViewerViewModel = + wireAssistedMetroViewModel { imageViewer(navArgs) } @Composable -fun cellAudioPlayerViewModel(): AudioPlayerViewModel = cellsViewModel() +internal fun cellAudioPlayerViewModel( + context: android.content.Context, + navArgs: AudioPlayerNavArgs, +): AudioPlayerViewModel = + wireAssistedMetroViewModel { + audioPlayer(context, navArgs) + } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index 3be6dd51cff..382e080ad31 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -24,10 +24,10 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -45,40 +45,17 @@ import androidx.compose.ui.res.stringResource import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems -import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.RecycleBinScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog -import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet -import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.feature.cells.ui.search.sort.SortBy import com.wire.android.feature.cells.ui.search.sort.SortRowWithMenu import com.wire.android.feature.cells.ui.search.sort.SortingCriteria -import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.PreviewNavigator -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.navigation.transition.LocalSharedTransitionScope import com.wire.android.navigation.transition.SHARED_ELEMENT_SEARCH_INPUT_KEY import com.wire.android.navigation.transition.SHARED_ELEMENT_TOP_APP_BAR_KEY @@ -100,20 +77,11 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf -/** - * Show files in one conversation. - * Conversation id is passed to view model via navigation parameters [CellFilesNavArgs]. - */ -@WireCellsDestination( - start = true, - style = PopUpNavigationAnimation::class, - navArgs = CellFilesNavArgs::class, -) @Composable -fun ConversationFilesScreen( - navigator: WireNavigator, +internal fun ConversationFilesRouteScreen( + navigation: CellsFilesNavigation, animatedVisibilityScope: AnimatedVisibilityScope, - viewModel: CellViewModel = cellViewModel(), + viewModel: CellViewModel, ) { val isOnlineState by viewModel.isOnline.collectAsState() // When offline files are disabled, never enter offline mode so all offline UI stays hidden. @@ -121,7 +89,7 @@ fun ConversationFilesScreen( ConversationFilesScreenContent( animatedVisibilityScope = animatedVisibilityScope, - navigator = navigator, + navigation = navigation, currentNodeUuid = viewModel.currentNodeUuid(), isRecycleBin = viewModel.isRecycleBin(), actions = viewModel.actions, @@ -152,7 +120,7 @@ fun ConversationFilesScreen( @Composable internal fun ConversationFilesScreenContent( animatedVisibilityScope: AnimatedVisibilityScope, - navigator: WireNavigator, + navigation: CellsFilesNavigation, currentNodeUuid: String?, isSearchResult: Boolean, actions: Flow, @@ -199,7 +167,7 @@ internal fun ConversationFilesScreenContent( }, onCreateFolder = { newActionBottomSheetState.hide() - navigator.navigate(NavigationCommand(CreateFolderScreenDestination(currentNodeUuid))) + navigation.createFolder(currentNodeUuid) }, onCreateFile = { newActionBottomSheetState.hide() @@ -213,13 +181,11 @@ internal fun ConversationFilesScreenContent( optionsBottomSheetState.hide() }, showRecycleBin = { - navigator.navigate( - NavigationCommand( - RecycleBinScreenDestination( - conversationId = currentNodeUuid?.substringBefore("/"), - isRecycleBin = true, - breadcrumbs = arrayOf(breadcrumbs?.first() ?: ""), - ) + navigation.recycleBin( + CellFilesNavArgs( + conversationId = currentNodeUuid?.substringBefore("/"), + isRecycleBin = true, + breadcrumbs = arrayOf(breadcrumbs?.first() ?: ""), ) ) optionsBottomSheetState.hide() @@ -233,7 +199,7 @@ internal fun ConversationFilesScreenContent( }, onItemSelected = { currentNodeUuid?.let { uuid -> - navigator.navigate(NavigationCommand(CreateFileScreenDestination(CreateFileScreenNavArgs(uuid, it)))) + navigation.createFile(uuid, it) } fileTypeBottomSheetState.hide() }, @@ -248,7 +214,7 @@ internal fun ConversationFilesScreenContent( sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_TOP_APP_BAR_KEY), animatedVisibilityScope = animatedVisibilityScope ), - onNavigationPressed = { navigator.navigateBack() }, + onNavigationPressed = navigation::back, title = screenTitle ?: stringResource(R.string.conversation_files_title), navigationIconType = NavigationIconType.Back(), elevation = dimensions().spacing0x, @@ -274,9 +240,7 @@ internal fun ConversationFilesScreenContent( searchQueryTextState = TextFieldState(), onTap = { currentNodeUuid?.let { - navigator.navigate( - NavigationCommand(SearchScreenDestination(conversationId = it)) - ) + navigation.search(it) } }, ) @@ -335,106 +299,24 @@ internal fun ConversationFilesScreenContent( isRecycleBin = isRecycleBin, isOffline = !isOnline, openFolder = { path, title, parentFolderUuid -> - navigator.navigate( - NavigationCommand( - ConversationFilesWithSlideInTransitionScreenDestination( - conversationId = path, - screenTitle = title, - isRecycleBin = isRecycleBin, - parentFolderUuid = parentFolderUuid, - breadcrumbs = (breadcrumbs ?: emptyArray()) + title - ), - BackStackMode.NONE, - launchSingleTop = false - ) - ) - }, - showPublicLinkScreen = { publicLinkScreenData -> - navigator.navigate( - NavigationCommand( - PublicLinkScreenDestination( - assetId = publicLinkScreenData.assetId, - fileName = publicLinkScreenData.fileName, - publicLinkId = publicLinkScreenData.linkId, - isFolder = publicLinkScreenData.isFolder - ) - ) - ) - }, - showMoveToFolderScreen = { currentPath, nodePath, uuid -> - navigator.navigate( - NavigationCommand( - MoveToFolderScreenDestination( - currentPath = currentPath, - nodeToMovePath = nodePath, - uuid = uuid - ) - ) - ) - }, - showRenameScreen = { cellNodeUi -> - navigator.navigate( - NavigationCommand( - RenameNodeScreenDestination( - uuid = cellNodeUi.uuid, - currentPath = cellNodeUi.remotePath, - isFolder = cellNodeUi is CellNodeUi.Folder, - nodeName = cellNodeUi.name, - ) - ) - ) - }, - showAddRemoveTagsScreen = { node -> - navigator.navigate( - NavigationCommand( - AddRemoveTagsScreenDestination(node.uuid, node.tags.toCollection(ArrayList())) - ) - ) - }, - showVersionHistoryScreen = { uuid, fileName -> - navigator.navigate(NavigationCommand(VersionHistoryScreenDestination(uuid, fileName))) - }, - showImageViewer = { file -> - navigator.navigate( - NavigationCommand( - CellImageViewerScreenDestination( - CellImageViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - previewUrl = file.previewUrl, - contentHash = file.contentHash, - fileName = file.name, - ) - ) - ) - ) - }, - showVideoViewer = { file -> - navigator.navigate( - NavigationCommand( - VideoPlayerScreenDestination( - VideoViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, - showAudioPlayer = { file -> - navigator.navigate( - NavigationCommand( - CellAudioPlayerScreenDestination( - AudioPlayerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) + navigation.folder( + CellFilesNavArgs( + conversationId = path, + screenTitle = title, + isRecycleBin = isRecycleBin, + parentFolderUuid = parentFolderUuid, + breadcrumbs = (breadcrumbs ?: emptyArray()) + title, ) ) }, + showPublicLinkScreen = navigation::publicLink, + showMoveToFolderScreen = navigation::move, + showRenameScreen = navigation::rename, + showAddRemoveTagsScreen = navigation::tags, + showVersionHistoryScreen = navigation::versionHistory, + showImageViewer = navigation::image, + showVideoViewer = navigation::video, + showAudioPlayer = navigation::audio, retryEditNodeError = { retryEditNodeError(it) }, isRefreshing = isRefreshing, onRefresh = onRefresh, @@ -453,7 +335,7 @@ fun PreviewConversationFilesScreen() { AnimatedVisibility(visible = true) { ConversationFilesScreenContent( animatedVisibilityScope = this, - navigator = PreviewNavigator, + navigation = NoOpCellsFilesNavigation, currentNodeUuid = "conversationId", isSearchResult = false, actions = flowOf(), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt index d793b405939..9d5cbabc0f7 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt @@ -23,42 +23,30 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.res.stringResource import androidx.paging.compose.collectAsLazyPagingItems -import com.ramcosta.composedestinations.generated.cells.destinations.RecycleBinScreenDestination import com.wire.android.feature.cells.R -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.SlideNavigationAnimation -@WireCellsDestination( - style = SlideNavigationAnimation::class, - navArgs = CellFilesNavArgs::class, -) @Composable -fun ConversationFilesWithSlideInTransitionScreen( - navigator: WireNavigator, +internal fun ConversationFilesSlideRouteScreen( + navigation: CellsFilesNavigation, cellFilesNavArgs: CellFilesNavArgs, animatedVisibilityScope: AnimatedVisibilityScope, - viewModel: CellViewModel = cellViewModel(), + viewModel: CellViewModel, ) { LaunchedEffect(viewModel.navigateToRecycleBinRoot.collectAsState().value) { if (viewModel.navigateToRecycleBinRoot.value) { - navigator.navigate( - NavigationCommand( - RecycleBinScreenDestination( - conversationId = viewModel.currentNodeUuid()?.substringBefore("/"), - isRecycleBin = true - ), - BackStackMode.POP_CONSECUTIVE_SAME_SCREENS - ) + navigation.recycleBin( + CellFilesNavArgs( + conversationId = viewModel.currentNodeUuid()?.substringBefore("/"), + isRecycleBin = true, + ), + popConsecutive = true, ) } } ConversationFilesScreenContent( animatedVisibilityScope = animatedVisibilityScope, - navigator = navigator, + navigation = navigation, currentNodeUuid = viewModel.currentNodeUuid(), isSearchResult = false, screenTitle = stringResource(R.string.conversation_files_title), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt index 6a83150b894..3e3fe4ea692 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt @@ -60,10 +60,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.wire.android.feature.cells.R -import com.wire.android.feature.cells.ui.cellAudioPlayerViewModel -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews @@ -77,15 +73,11 @@ import kotlin.time.Duration.Companion.milliseconds private const val SKIP_S = 12 private val SKIP_MS = SKIP_S.milliseconds.inWholeMilliseconds.toInt() -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = AudioPlayerNavArgs::class, -) @Composable -fun CellAudioPlayerScreen( - navigator: WireNavigator, +internal fun CellAudioPlayerRouteScreen( + onNavigateBack: () -> Unit, + viewModel: AudioPlayerViewModel, modifier: Modifier = Modifier, - viewModel: AudioPlayerViewModel = cellAudioPlayerViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() CellAudioPlayerContent( @@ -94,7 +86,7 @@ fun CellAudioPlayerScreen( onTogglePlayPause = viewModel::togglePlayPause, onSeek = viewModel::seekTo, onStop = viewModel::pause, - onNavigateBack = navigator::navigateBack, + onNavigateBack = onNavigateBack, modifier = modifier, ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt index 80933acea06..232cfb603fc 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt @@ -20,12 +20,9 @@ package com.wire.android.feature.cells.ui.audioplayer import android.content.Context import android.media.MediaPlayer import android.net.Uri -import androidx.lifecycle.SavedStateHandle import java.io.File import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination -import com.wire.android.di.ApplicationContext import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -39,20 +36,17 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch class AudioPlayerViewModel @AssistedInject constructor( - @ApplicationContext context: Context, - @Assisted savedStateHandle: SavedStateHandle, + @Assisted context: Context, + @Assisted val localPath: String?, + @Assisted val contentUrl: String?, + @Assisted val fileName: String?, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): AudioPlayerViewModel + fun create(context: Context, localPath: String?, contentUrl: String?, fileName: String?): AudioPlayerViewModel } - private val navArgs = CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) - val localPath: String? = navArgs.localPath - val contentUrl: String? = navArgs.contentUrl - val fileName: String? = navArgs.fileName - private val _state = MutableStateFlow(AudioPlaybackState()) val state: StateFlow = _state.asStateFlow() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt index 4f90de15a03..5f45a6008a8 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt @@ -29,14 +29,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.DialogProperties import com.wire.android.feature.cells.ui.createFileViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.FileNameError -import com.wire.android.navigation.PreviewNavigator -import com.wire.android.navigation.PreviewResultBackNavigator -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -55,16 +49,12 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import androidx.compose.ui.platform.LocalLocale -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = CreateFileScreenNavArgs::class, -) @Composable -fun CreateFileScreen( - navigator: WireNavigator, - resultNavigator: ResultBackNavigator, +internal fun CreateFileRouteScreen( + onNavigateBack: () -> Unit, + onCreated: () -> Unit, modifier: Modifier = Modifier, - createFileViewModel: CreateFileViewModel = createFileViewModel(), + createFileViewModel: CreateFileViewModel, ) { val showErrorDialog = remember { mutableStateOf(false) } @@ -86,7 +76,7 @@ fun CreateFileScreen( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = { navigator.navigateBack() }, + onNavigationPressed = onNavigateBack, navigationIconType = NavigationIconType.Close(), elevation = dimensions().spacing0x, title = stringResource(id = R.string.create_file_screen_title, createFileViewModel.fileExtension), @@ -138,8 +128,7 @@ fun CreateFileScreen( HandleActions(createFileViewModel.actions) { action -> when (action) { CreateFileViewModelAction.Success -> { - resultNavigator.setResult(true) - resultNavigator.navigateBack() + onCreated() } CreateFileViewModelAction.Failure -> { @@ -166,9 +155,10 @@ private fun computeNameErrorState(error: FileNameError?): WireTextFieldState { @Composable fun PreviewCreateFileScreen() { WireTheme { - CreateFileScreen( - navigator = PreviewNavigator, - resultNavigator = PreviewResultBackNavigator as ResultBackNavigator, + CreateFileRouteScreen( + onNavigateBack = {}, + onCreated = {}, + createFileViewModel = createFileViewModel(CreateFileScreenNavArgs("", FileType.DOCUMENT)), ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt index ec83bca5d44..30c33b7f4b2 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt @@ -21,9 +21,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.android.feature.cells.ui.common.validateFileName import com.wire.android.ui.common.ActionsViewModel @@ -41,7 +39,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class CreateFileViewModel @AssistedInject constructor( - @Assisted private val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: CreateFileScreenNavArgs, private val createPresentationFileUseCase: CreatePresentationFileUseCase, private val createDocumentFileUseCase: CreateDocumentFileUseCase, private val createSpreadsheetFileUseCase: CreateSpreadsheetFileUseCase @@ -49,11 +47,9 @@ class CreateFileViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateFileViewModel + fun create(navArgs: CreateFileScreenNavArgs): CreateFileViewModel } - private val navArgs: CreateFileScreenNavArgs = CreateFileScreenDestination.argsFrom(savedStateHandle) - val fileExtension: String = navArgs.fileType.getExtension() val fileNameTextFieldState: TextFieldState = TextFieldState() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt index 374771477cd..14af43e7a0a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt @@ -34,14 +34,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.DialogProperties import com.wire.android.feature.cells.ui.createFolderViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.FileNameError -import com.wire.android.navigation.PreviewNavigator -import com.wire.android.navigation.PreviewResultBackNavigator -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -60,16 +54,12 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import androidx.compose.ui.platform.LocalLocale -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = CreateFolderScreenNavArgs::class, -) @Composable -fun CreateFolderScreen( - navigator: WireNavigator, - resultNavigator: ResultBackNavigator, +internal fun CreateFolderRouteScreen( + onNavigateBack: () -> Unit, + onCreated: () -> Unit, modifier: Modifier = Modifier, - createFolderViewModel: CreateFolderViewModel = createFolderViewModel(), + createFolderViewModel: CreateFolderViewModel, ) { val showErrorDialog = remember { mutableStateOf(false) } @@ -91,7 +81,7 @@ fun CreateFolderScreen( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = { navigator.navigateBack() }, + onNavigationPressed = onNavigateBack, navigationIconType = NavigationIconType.Close(), elevation = dimensions().spacing0x, title = stringResource(id = R.string.cells_create_folder), @@ -151,8 +141,7 @@ fun CreateFolderScreen( HandleActions(createFolderViewModel.actions) { action -> when (action) { CreateFolderViewModelAction.Success -> { - resultNavigator.setResult(true) - resultNavigator.navigateBack() + onCreated() } CreateFolderViewModelAction.Failure -> { showErrorDialog.value = true @@ -178,9 +167,10 @@ private fun computeNameErrorState(error: FileNameError?): WireTextFieldState { @Composable fun PreviewCreateFolderScreen() { WireTheme { - CreateFolderScreen( - navigator = PreviewNavigator, - resultNavigator = PreviewResultBackNavigator as ResultBackNavigator, + CreateFolderRouteScreen( + onNavigateBack = {}, + onCreated = {}, + createFolderViewModel = createFolderViewModel(CreateFolderScreenNavArgs(null)), ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt index e278159dc01..47e3bbee0e8 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt @@ -21,9 +21,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.android.feature.cells.ui.common.validateFileName import com.wire.android.ui.common.ActionsViewModel @@ -39,17 +37,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class CreateFolderViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: CreateFolderScreenNavArgs, private val createFolderUseCase: CreateFolderUseCase, ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CreateFolderViewModel + fun create(navArgs: CreateFolderScreenNavArgs): CreateFolderViewModel } - private val navArgs: CreateFolderScreenNavArgs = CreateFolderScreenDestination.argsFrom(savedStateHandle) - val folderNameTextFieldState: TextFieldState = TextFieldState() var viewState: CreateFolderViewState by mutableStateOf(CreateFolderViewState()) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt index 023386842ed..eab8094a3ec 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt @@ -32,10 +32,6 @@ import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade import com.wire.android.feature.cells.R -import com.wire.android.feature.cells.ui.cellImageViewerViewModel -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.image.ZoomableImageContainer import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.common.scaffold.WireScaffold @@ -43,27 +39,6 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.WireTheme -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = CellImageViewerNavArgs::class, -) -@Composable -fun CellImageViewerScreen( - navigator: WireNavigator, - modifier: Modifier = Modifier, - viewModel: CellImageViewerViewModel = cellImageViewerViewModel(), -) { - CellImageViewerScreenContent( - localPath = viewModel.localPath, - contentUrl = viewModel.contentUrl, - previewUrl = viewModel.previewUrl, - contentHash = viewModel.contentHash, - fileName = viewModel.fileName, - onNavigateBack = navigator::navigateBack, - modifier = modifier, - ) -} - @Composable internal fun CellImageViewerScreenContent( localPath: String?, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt index 5de7ec62db3..3ddd6187cf1 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt @@ -17,24 +17,20 @@ */ package com.wire.android.feature.cells.ui.imageviewer -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject class CellImageViewerViewModel @AssistedInject constructor( - @Assisted private val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: CellImageViewerNavArgs, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): CellImageViewerViewModel + fun create(navArgs: CellImageViewerNavArgs): CellImageViewerViewModel } - private val navArgs: CellImageViewerNavArgs = CellImageViewerScreenDestination.argsFrom(savedStateHandle) - val localPath: String? = navArgs.localPath val contentUrl: String? = navArgs.contentUrl val previewUrl: String? = navArgs.previewUrl diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt index b2929f72795..8408e13c4a9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt @@ -42,19 +42,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.wire.android.feature.cells.ui.moveToFolderViewModel -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.Breadcrumbs import com.wire.android.feature.cells.ui.common.LoadingScreen -import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.wire.android.model.ClickBlockParams -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.PreviewNavigator -import com.wire.android.navigation.PreviewResultRecipient -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -70,16 +61,15 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -@Suppress("CyclomaticComplexMethod") -@WireCellsDestination( - navArgs = MoveToFolderNavArgs::class, -) @Composable -fun MoveToFolderScreen( - navigator: WireNavigator, - createFolderResultRecipient: ResultRecipient, +internal fun MoveToFolderRouteScreen( + onNavigateBack: () -> Unit, + onCloseFlow: () -> Unit, + onNavigateBackSteps: (Int) -> Unit, + onOpenCreateFolder: (String) -> Unit, + onOpenFolder: (String, String, String, List) -> Unit, modifier: Modifier = Modifier, - moveToFolderViewModel: MoveToFolderViewModel = moveToFolderViewModel(), + moveToFolderViewModel: MoveToFolderViewModel, ) { val context = LocalContext.current val viewState by moveToFolderViewModel.state.collectAsState() @@ -91,7 +81,7 @@ fun MoveToFolderScreen( TopScreenBar( breadcrumbs = viewState.breadcrumbs.toTypedArray(), onBreadcrumbClick = moveToFolderViewModel::onBreadcrumbClick, - onNavigateBack = { navigator.navigateBack() } + onNavigateBack = onNavigateBack, ) }, bottomBar = { @@ -137,52 +127,24 @@ fun MoveToFolderScreen( when (action) { is MoveToFolderViewAction.Success -> { Toast.makeText(context, context.resources.getString(R.string.item_move_success), Toast.LENGTH_SHORT).show() - navigator.navigateBackAndRemoveAllConsecutive(MoveToFolderScreenDestination.route) + onCloseFlow() } is MoveToFolderViewAction.Failure -> { Toast.makeText(context, context.resources.getString(R.string.item_move_failure), Toast.LENGTH_SHORT).show() - navigator.navigateBackAndRemoveAllConsecutive(MoveToFolderScreenDestination.route) + onCloseFlow() } is MoveToFolderViewAction.NavigateToBreadcrumb -> { - navigator.navigateBackAndRemoveAllConsecutiveXTimes( - currentRoute = MoveToFolderScreenDestination.route, - stepsBack = action.steps - ) + onNavigateBackSteps(action.steps) } is MoveToFolderViewAction.OpenCreateFolderScreen -> { - navigator.navigate( - NavigationCommand( - CreateFolderScreenDestination(action.currentPath) - ) - ) + onOpenCreateFolder(action.currentPath) } is MoveToFolderViewAction.OpenFolder -> { - navigator.navigate( - NavigationCommand( - MoveToFolderScreenDestination( - currentPath = action.path, - nodeToMovePath = action.nodePath, - uuid = action.nodeUuid, - breadcrumbs = action.breadcrumbs.toTypedArray() - ), - launchSingleTop = false - ) - ) - } - } - } - - createFolderResultRecipient.onNavResult { result -> - when (result) { - NavResult.Canceled -> {} - is NavResult.Value -> { - if (result.value) { - moveToFolderViewModel.loadFolders() - } + onOpenFolder(action.path, action.nodePath, action.nodeUuid, action.breadcrumbs) } } } @@ -290,9 +252,13 @@ private fun EmptyScreen(isRootFolder: Boolean) { @Composable fun PreviewMoveToFolderScreen() { WireTheme { - MoveToFolderScreen( - navigator = PreviewNavigator, - createFolderResultRecipient = PreviewResultRecipient as ResultRecipient + MoveToFolderRouteScreen( + onNavigateBack = {}, + onCloseFlow = {}, + onNavigateBackSteps = {}, + onOpenCreateFolder = {}, + onOpenFolder = { _, _, _, _ -> }, + moveToFolderViewModel = moveToFolderViewModel(MoveToFolderNavArgs("", "", "")), ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModel.kt index 996bfa4f368..da935f4e729 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModel.kt @@ -17,9 +17,7 @@ */ package com.wire.android.feature.cells.ui.movetofolder -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.toUiModel import com.wire.android.ui.common.ActionsViewModel @@ -36,18 +34,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class MoveToFolderViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: MoveToFolderNavArgs, private val getFoldersUseCase: GetFoldersUseCase, private val moveNodeUseCase: MoveNodeUseCase ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): MoveToFolderViewModel + fun create(navArgs: MoveToFolderNavArgs): MoveToFolderViewModel } - private val navArgs: MoveToFolderNavArgs = MoveToFolderScreenDestination.argsFrom(savedStateHandle) - private val currentPath: String = navArgs.currentPath private val nodeToMovePath: String = navArgs.nodeToMovePath private val nodeUuid: String = navArgs.uuid diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt index a13b78749ce..555f5a8631b 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt @@ -44,22 +44,11 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import com.wire.android.feature.cells.ui.publicLinkViewModel -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient -import com.ramcosta.composedestinations.spec.TypedDestinationSpec import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.WireCellErrorDialog -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkExpirationScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkPasswordScreenDestination import com.wire.android.feature.cells.ui.publiclink.settings.PublicLinkSettingsSection import com.wire.android.feature.cells.ui.publiclink.settings.RemovePublicLinkDialog -import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationResult import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.button.WireSwitch import com.wire.android.ui.common.colorsScheme @@ -72,19 +61,14 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.uiLinkExpirationDate import com.wire.android.util.uiLinkExpirationTime -@WireCellsDestination( - navArgs = PublicLinkNavArgs::class, - style = PopUpNavigationAnimation::class, -) @Composable -fun PublicLinkScreen( - navigator: WireNavigator, - onPasswordChange: ResultRecipient, - onExpirationChange: ResultRecipient, +internal fun PublicLinkRouteScreen( + onNavigateBack: () -> Unit, + onOpenPasswordSettings: (String, Boolean) -> Unit, + onOpenExpirationSettings: (String, Long?) -> Unit, modifier: Modifier = Modifier, - viewModel: PublicLinkViewModel = publicLinkViewModel(), + viewModel: PublicLinkViewModel, ) { - val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val state by viewModel.state.collectAsState() @@ -96,7 +80,7 @@ fun PublicLinkScreen( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = { navigator.navigateBack() }, + onNavigationPressed = onNavigateBack, title = if (state.isFolder) { stringResource(R.string.share_folder_via_link) } else { @@ -172,7 +156,7 @@ fun PublicLinkScreen( Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() if (action.closeScreen) { - navigator.navigateBack() + onNavigateBack() } } @@ -181,39 +165,21 @@ fun PublicLinkScreen( showLinkCopiedToast(context) } - is OpenPasswordSettings -> - navigator.navigate( - NavigationCommand( - PublicLinkPasswordScreenDestination( - linkUuid = action.linkUuid, - passwordEnabled = action.isPasswordEnabled, - ) - ) - ) + is OpenPasswordSettings -> onOpenPasswordSettings( + action.linkUuid, + action.isPasswordEnabled, + ) - is OpenExpirationSettings -> - navigator.navigate( - NavigationCommand( - PublicLinkExpirationScreenDestination( - linkUuid = action.linkUuid, - expiresAt = action.expiresAt, - ) - ) - ) + is OpenExpirationSettings -> onOpenExpirationSettings( + action.linkUuid, + action.expiresAt, + ) ShowRemoveConfirmation -> showRemoveConfirmationDialog = true is ShowErrorDialog -> showErrorDialog = action.error } } - - onPasswordChange.handleNavResult { result -> - viewModel.onPasswordUpdate(result) - } - - onExpirationChange.handleNavResult { result -> - viewModel.onExpirationUpdate(result) - } } @Composable @@ -272,16 +238,6 @@ private fun showLinkCopiedToast(context: Context) { } } -@Composable -private fun , R> ResultRecipient.handleNavResult(block: (R) -> Unit) { - onNavResult { result -> - when (result) { - is NavResult.Value -> block(result.value) - NavResult.Canceled -> {} - } - } -} - @Composable private fun PublicLinkSettings.formattedExpirationError() = expiresAt?.let { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModel.kt index cb3244d50f2..fb61922dc99 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModel.kt @@ -17,9 +17,7 @@ */ package com.wire.android.feature.cells.ui.publiclink -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationResult import com.wire.android.feature.cells.util.FileHelper @@ -39,7 +37,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class PublicLinkViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: PublicLinkNavArgs, private val createPublicLink: CreatePublicLinkUseCase, private val getPublicLinkUseCase: GetPublicLinkUseCase, private val deletePublicLinkUseCase: DeletePublicLinkUseCase, @@ -48,11 +46,9 @@ class PublicLinkViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): PublicLinkViewModel + fun create(navArgs: PublicLinkNavArgs): PublicLinkViewModel } - private val navArgs: PublicLinkNavArgs = PublicLinkScreenDestination.argsFrom(savedStateHandle) - private val _state = MutableStateFlow( PublicLinkViewState( isEnabled = navArgs.publicLinkId != null, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt index 8e4d5f14e88..7e3cc6248cf 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -39,12 +38,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.wire.android.feature.cells.ui.publicLinkExpirationScreenViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.WireCellErrorDialog import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -64,17 +60,12 @@ import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme import kotlinx.parcelize.Parcelize -@OptIn(ExperimentalMaterial3Api::class) -@WireCellsDestination( - navArgs = PublicLinkExpirationScreenNavArgs::class -) @Composable -internal fun PublicLinkExpirationScreen( - resultNavigator: ResultBackNavigator, +internal fun PublicLinkExpirationRouteScreen( + onResult: (PublicLinkExpirationResult) -> Unit, modifier: Modifier = Modifier, - viewModel: PublicLinkExpirationScreenViewModel = publicLinkExpirationScreenViewModel(), + viewModel: PublicLinkExpirationScreenViewModel, ) { - val state by viewModel.state.collectAsState() var showDatePicker by remember { mutableStateOf(null) } @@ -83,7 +74,7 @@ internal fun PublicLinkExpirationScreen( BackHandler { if (!state.showProgress) { - resultNavigator.navigateBack(viewModel.getResult()) + onResult(viewModel.getResult()) } } @@ -93,7 +84,7 @@ internal fun PublicLinkExpirationScreen( WireCenterAlignedTopAppBar( onNavigationPressed = { if (!state.showProgress) { - resultNavigator.navigateBack(viewModel.getResult()) + onResult(viewModel.getResult()) } }, title = stringResource(R.string.public_link_setting_expiration_title), @@ -159,7 +150,7 @@ internal fun PublicLinkExpirationScreen( is ShowDatePicker -> showDatePicker = SelectedDate(action.selectedDate) is ShowTimePicker -> showTimePicker = SelectedTime(action.selectedTime) is ShowError -> showError = action.error - is CloseScreen -> resultNavigator.navigateBack(action.result) + is CloseScreen -> onResult(action.result) } } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModel.kt index 16244218fa9..1c28cb3e703 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModel.kt @@ -17,9 +17,7 @@ */ package com.wire.android.feature.cells.ui.publiclink.settings.expiration -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkExpirationScreenDestination import com.wire.android.feature.cells.R import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.datetime.TimePickerResult @@ -46,16 +44,14 @@ import kotlinx.datetime.toLocalDateTime internal class PublicLinkExpirationScreenViewModel @AssistedInject constructor( val setExpiration: SetPublicLinkExpirationUseCase, - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: PublicLinkExpirationScreenNavArgs, ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): PublicLinkExpirationScreenViewModel + fun create(navArgs: PublicLinkExpirationScreenNavArgs): PublicLinkExpirationScreenViewModel } - private val navArgs: PublicLinkExpirationScreenNavArgs = PublicLinkExpirationScreenDestination.argsFrom(savedStateHandle) - var isExpirationSet: Boolean = navArgs.expiresAt != null private set diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt index 3fcc87e4b66..0ff524cf32a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt @@ -44,13 +44,10 @@ import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource -import com.wire.android.feature.cells.ui.publicLinkPasswordScreenViewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.WireCellErrorDialog import com.wire.android.feature.cells.ui.publiclink.settings.RemovePasswordDialog import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.button.WireSwitch import com.wire.android.ui.common.colorsScheme @@ -62,14 +59,11 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme -@WireCellsDestination( - navArgs = PublicLinkPasswordNavArgs::class, -) @Composable -internal fun PublicLinkPasswordScreen( - resultNavigator: ResultBackNavigator, +internal fun PublicLinkPasswordRouteScreen( + onResult: (Boolean) -> Unit, modifier: Modifier = Modifier, - viewModel: PublicLinkPasswordScreenViewModel = publicLinkPasswordScreenViewModel(), + viewModel: PublicLinkPasswordScreenViewModel, ) { val state by viewModel.state.collectAsState() val clipboardManager = LocalClipboardManager.current @@ -80,7 +74,7 @@ internal fun PublicLinkPasswordScreen( BackHandler { if (!state.showProgress) { - resultNavigator.navigateBack(viewModel.isPasswordCreated) + onResult(viewModel.isPasswordCreated) } } @@ -90,7 +84,7 @@ internal fun PublicLinkPasswordScreen( WireCenterAlignedTopAppBar( onNavigationPressed = { if (!state.showProgress) { - resultNavigator.navigateBack(viewModel.isPasswordCreated) + onResult(viewModel.isPasswordCreated) } }, title = stringResource(R.string.public_link_setting_password_title), @@ -145,7 +139,7 @@ internal fun PublicLinkPasswordScreen( when (action) { is CopyPasswordAndClose -> { copyPassword(clipboardManager, action.password) - resultNavigator.navigateBack(true) + onResult(true) } ShowMissingPasswordDialog -> showMissingPasswordDialog = true ShowRemoveConfirmationDialog -> showRemoveConfirmationDialog = true diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModel.kt index 6eab0df8d4d..cc56d397201 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModel.kt @@ -20,9 +20,7 @@ package com.wire.android.feature.cells.ui.publiclink.settings.password import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkPasswordScreenDestination import com.wire.android.feature.cells.R import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.textfield.textAsFlow @@ -46,16 +44,14 @@ internal class PublicLinkPasswordScreenViewModel @AssistedInject constructor( private val createPassword: CreatePublicLinkPasswordUseCase, private val updatePassword: UpdatePublicLinkPasswordUseCase, private val getPublicLinkPassword: GetPublicLinkPasswordUseCase, - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: PublicLinkPasswordNavArgs, ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): PublicLinkPasswordScreenViewModel + fun create(navArgs: PublicLinkPasswordNavArgs): PublicLinkPasswordScreenViewModel } - private val navArgs: PublicLinkPasswordNavArgs = PublicLinkPasswordScreenDestination.argsFrom(savedStateHandle) - internal var isPasswordCreated = navArgs.passwordEnabled private set diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index 3bdc35eb462..6a68cd39a3e 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -32,17 +32,8 @@ import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.CellFilesNavArgs import com.wire.android.feature.cells.ui.CellScreenContent import com.wire.android.feature.cells.ui.CellViewModel -import com.wire.android.feature.cells.ui.cellViewModel +import com.wire.android.feature.cells.ui.CellsFilesNavigation import com.wire.android.feature.cells.ui.common.Breadcrumbs -import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination -import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType @@ -50,16 +41,12 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.WireTopAppBarTitle import com.wire.android.ui.theme.wireTypography -@WireCellsDestination( - navArgs = CellFilesNavArgs::class, -) @Composable -fun RecycleBinScreen( - navigator: WireNavigator, +internal fun RecycleBinRouteScreen( + navigation: CellsFilesNavigation, modifier: Modifier = Modifier, - cellViewModel: CellViewModel = cellViewModel() + cellViewModel: CellViewModel, ) { - Box(modifier = modifier) { WireScaffold( topBar = { @@ -75,7 +62,7 @@ fun RecycleBinScreen( }, navigationIconType = NavigationIconType.Back(com.wire.android.ui.common.R.string.content_description_back_button), onNavigationPressed = { - navigator.navigateBack() + navigation.back() } ) @@ -102,60 +89,21 @@ fun RecycleBinScreen( isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = cellViewModel.isDeleteInProgress.collectAsState().value, openFolder = { path, title, parentFolderUuid -> - navigator.navigate( - NavigationCommand( - ConversationFilesWithSlideInTransitionScreenDestination( - conversationId = path, - screenTitle = title, - isRecycleBin = true, - breadcrumbs = (cellViewModel.breadcrumbs() ?: emptyArray()) + title, - parentFolderUuid = parentFolderUuid, - ), - BackStackMode.NONE, - launchSingleTop = false - ) - ) - }, - showPublicLinkScreen = { publicLinkScreenData -> - navigator.navigate( - NavigationCommand( - PublicLinkScreenDestination( - assetId = publicLinkScreenData.assetId, - fileName = publicLinkScreenData.fileName, - publicLinkId = publicLinkScreenData.linkId, - isFolder = publicLinkScreenData.isFolder - ) - ) - ) - }, - showMoveToFolderScreen = { currentPath, nodePath, uuid -> - navigator.navigate( - NavigationCommand( - MoveToFolderScreenDestination( - currentPath = currentPath, - nodeToMovePath = nodePath, - uuid = uuid, - ) + navigation.folder( + CellFilesNavArgs( + conversationId = path, + screenTitle = title, + isRecycleBin = true, + breadcrumbs = (cellViewModel.breadcrumbs() ?: emptyArray()) + title, + parentFolderUuid = parentFolderUuid, ) ) }, + showPublicLinkScreen = navigation::publicLink, + showMoveToFolderScreen = navigation::move, showRenameScreen = { }, showAddRemoveTagsScreen = {}, - showImageViewer = { file -> - navigator.navigate( - NavigationCommand( - CellImageViewerScreenDestination( - CellImageViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - previewUrl = file.previewUrl, - contentHash = file.contentHash, - fileName = file.name, - ) - ) - ) - ) - }, + showImageViewer = navigation::image, isRefreshing = cellViewModel.isPullToRefresh.collectAsState(), onRefresh = { cellViewModel.onPullToRefresh() } ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt index f5004d6a896..7bb174adb79 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt @@ -35,10 +35,6 @@ import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.FILE_NAME_MAX_COUNT import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.android.model.ClickBlockParams -import com.wire.android.navigation.PreviewNavigator -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState.Default @@ -57,15 +53,11 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = RenameNodeNavArgs::class, -) @Composable -fun RenameNodeScreen( - navigator: WireNavigator, +internal fun RenameNodeRouteScreen( + onNavigateBack: () -> Unit, modifier: Modifier = Modifier, - renameNodeViewModel: RenameNodeViewModel = renameNodeViewModel() + renameNodeViewModel: RenameNodeViewModel, ) { val context = LocalContext.current @@ -75,7 +67,7 @@ fun RenameNodeScreen( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = { navigator.navigateBack() }, + onNavigationPressed = onNavigateBack, title = if (renameNodeViewModel.isFolder()) { stringResource(R.string.rename_folder_label) } else { @@ -142,7 +134,7 @@ fun RenameNodeScreen( is RenameNodeViewModelAction.Failure -> Toast.makeText(context, R.string.rename_failure, Toast.LENGTH_SHORT).show() } - navigator.navigateBack() + onNavigateBack() } } @@ -169,8 +161,9 @@ private fun computeNameErrorState(error: FileNameError?, isFolder: Boolean): Wir @Composable fun PreviewRenameNodeScreen() { WireTheme { - RenameNodeScreen( - navigator = PreviewNavigator + RenameNodeRouteScreen( + onNavigateBack = {}, + renameNodeViewModel = renameNodeViewModel(RenameNodeNavArgs()), ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt index 6eb2e18d395..9d816f5a08f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt @@ -21,9 +21,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.android.feature.cells.ui.common.validateFileName import com.wire.android.ui.common.ActionsViewModel @@ -44,17 +42,15 @@ import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds class RenameNodeViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: RenameNodeNavArgs, private val renameNodeUseCase: RenameNodeUseCase, ) : ActionsViewModel() { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): RenameNodeViewModel + fun create(navArgs: RenameNodeNavArgs): RenameNodeViewModel } - private val navArgs: RenameNodeNavArgs = RenameNodeScreenDestination.argsFrom(savedStateHandle) - private var clearErrorJob: Job? = null fun isFolder(): Boolean = navArgs.isFolder ?: false diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index a5a45c6550f..da3846a2370 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -39,22 +39,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems -import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.CellScreenContent +import com.wire.android.feature.cells.ui.CellFilesNavArgs import com.wire.android.feature.cells.ui.CellViewModel -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner -import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs -import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet import com.wire.android.feature.cells.ui.search.filter.bottomsheet.conversation.FilterByConversationBottomSheet @@ -62,12 +51,6 @@ import com.wire.android.feature.cells.ui.search.filter.bottomsheet.owner.FilterB import com.wire.android.feature.cells.ui.search.filter.bottomsheet.tags.FilterByTagsBottomSheet import com.wire.android.feature.cells.ui.search.sort.SortRowWithMenu import com.wire.android.feature.cells.ui.searchScreenViewModel -import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs -import com.wire.android.navigation.BackStackMode -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.navigation.transition.LocalSharedTransitionScope import com.wire.android.navigation.transition.SHARED_ELEMENT_SEARCH_INPUT_KEY import com.wire.android.ui.common.bottomsheet.WireSheetValue @@ -78,17 +61,13 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchTopBar @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = SearchNavArgs::class, -) @Composable -fun SearchScreen( - navigator: WireNavigator, +internal fun SearchRouteScreen( + navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, animatedVisibilityScope: AnimatedVisibilityScope, cellViewModel: CellViewModel, modifier: Modifier = Modifier, - searchScreenViewModel: SearchScreenViewModel = searchScreenViewModel(), + searchScreenViewModel: SearchScreenViewModel, ) { val uiState by searchScreenViewModel.uiState.collectAsStateWithLifecycle() val isOnlineState by cellViewModel.isOnline.collectAsState() @@ -132,7 +111,7 @@ fun SearchScreen( DriveSearchScreenType.DRIVE -> stringResource(R.string.search_drive_text_input_hint) }, searchQueryTextState = searchState, - onCloseSearchClicked = { navigator.navigateBack() }, + onCloseSearchClicked = navigation::back, onActiveChanged = { searchScreenViewModel.onSetSearchActive(it) }, @@ -183,7 +162,7 @@ fun SearchScreen( WireCenterAlignedTopAppBar( title = "", navigationIconType = NavigationIconType.Close(), - onNavigationPressed = { navigator.navigateBack() }, + onNavigationPressed = navigation::back, ) OfflineBanner() } @@ -216,104 +195,22 @@ fun SearchScreen( isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = cellViewModel.isDeleteInProgress.collectAsState().value, openFolder = { path, title, parentFolderUuid -> - navigator.navigate( - NavigationCommand( - ConversationFilesWithSlideInTransitionScreenDestination( - conversationId = path, - screenTitle = title, - parentFolderUuid = parentFolderUuid, - ), - BackStackMode.NONE, - launchSingleTop = false - ) - ) - }, - showPublicLinkScreen = { publicLinkScreenData -> - navigator.navigate( - NavigationCommand( - PublicLinkScreenDestination( - assetId = publicLinkScreenData.assetId, - fileName = publicLinkScreenData.fileName, - publicLinkId = publicLinkScreenData.linkId, - isFolder = publicLinkScreenData.isFolder - ) - ) - ) - }, - showMoveToFolderScreen = { currentPath, nodePath, uuid -> - navigator.navigate( - NavigationCommand( - MoveToFolderScreenDestination( - currentPath = currentPath, - nodeToMovePath = nodePath, - uuid = uuid - ) - ) - ) - }, - showRenameScreen = { cellNodeUi -> - navigator.navigate( - NavigationCommand( - RenameNodeScreenDestination( - uuid = cellNodeUi.uuid, - currentPath = cellNodeUi.remotePath, - isFolder = cellNodeUi is CellNodeUi.Folder, - nodeName = cellNodeUi.name, - ) - ) - ) - }, - showAddRemoveTagsScreen = { node -> - navigator.navigate( - NavigationCommand( - AddRemoveTagsScreenDestination(node.uuid, node.tags.toCollection(ArrayList())) - ) - ) - }, - showVersionHistoryScreen = { uuid, fileName -> - navigator.navigate(NavigationCommand(VersionHistoryScreenDestination(uuid, fileName))) - }, - showImageViewer = { file -> - navigator.navigate( - NavigationCommand( - CellImageViewerScreenDestination( - CellImageViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - previewUrl = file.previewUrl, - contentHash = file.contentHash, - fileName = file.name, - ) - ) - ) - ) - }, - showVideoViewer = { file -> - navigator.navigate( - NavigationCommand( - VideoPlayerScreenDestination( - VideoViewerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, - showAudioPlayer = { file -> - navigator.navigate( - NavigationCommand( - CellAudioPlayerScreenDestination( - AudioPlayerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) + navigation.folder( + CellFilesNavArgs( + conversationId = path, + screenTitle = title, + parentFolderUuid = parentFolderUuid, ) ) }, + showPublicLinkScreen = navigation::publicLink, + showMoveToFolderScreen = navigation::move, + showRenameScreen = navigation::rename, + showAddRemoveTagsScreen = navigation::tags, + showVersionHistoryScreen = navigation::versionHistory, + showImageViewer = navigation::image, + showVideoViewer = navigation::video, + showAudioPlayer = navigation::audio, retryEditNodeError = { cellViewModel.editNode(it) }, isRefreshing = remember { mutableStateOf(false) }, onRefresh = { }, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt index 2524cb95a16..c68242eb1c3 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.search -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.LoadState @@ -25,7 +24,6 @@ import androidx.paging.LoadStates import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.feature.cells.ui.CellFileLocalPathCache import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.toUiModel @@ -74,7 +72,7 @@ private const val SEARCH_DEBOUNCE_MILLIS = 200L @Suppress("TooManyFunctions") class SearchScreenViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: SearchNavArgs, private val getAllTagsUseCase: GetAllTagsUseCase, private val getCellFilesPaged: GetPaginatedFilesFlowUseCase, private val getOwners: GetOwnersUseCase, @@ -85,7 +83,7 @@ class SearchScreenViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): SearchScreenViewModel + fun create(navArgs: SearchNavArgs): SearchScreenViewModel } private data class SearchParams( @@ -98,8 +96,6 @@ class SearchScreenViewModel @AssistedInject constructor( val conversationId: String?, ) - private val navArgs: SearchNavArgs = SearchScreenDestination.argsFrom(savedStateHandle) - val screenType = navArgs.screenType val parentRoute = navArgs.parentRoute diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt index 35c12548e7b..73c7437b559 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt @@ -50,9 +50,6 @@ import androidx.compose.ui.unit.dp import com.wire.android.feature.cells.ui.addRemoveTagsViewModel import com.wire.android.feature.cells.R import com.wire.android.model.ClickBlockParams -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled @@ -70,15 +67,11 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import kotlinx.coroutines.launch -@WireCellsDestination( - navArgs = AddRemoveTagsNavArgs::class, - style = PopUpNavigationAnimation::class, -) @Composable -fun AddRemoveTagsScreen( - navigator: WireNavigator, +internal fun AddRemoveTagsRouteScreen( + onNavigateBack: () -> Unit, modifier: Modifier = Modifier, - addRemoveTagsViewModel: AddRemoveTagsViewModel = addRemoveTagsViewModel(), + addRemoveTagsViewModel: AddRemoveTagsViewModel, ) { val context = LocalContext.current @@ -92,7 +85,7 @@ fun AddRemoveTagsScreen( title = stringResource(R.string.add_or_remove_tags_title), navigationIconType = NavigationIconType.Close(), onNavigationPressed = { - navigator.navigateBack() + onNavigateBack() }, elevation = dimensions().spacing0x, ) @@ -177,7 +170,7 @@ fun AddRemoveTagsScreen( Toast.makeText(context, context.resources.getString(R.string.failed_edit_tags), Toast.LENGTH_SHORT).show() } } - navigator.navigateBack() + onNavigateBack() } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt index ca6301697b2..92007de03a6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt @@ -20,9 +20,7 @@ package com.wire.android.feature.cells.ui.tags import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.runtime.snapshotFlow -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination import com.wire.android.ui.common.ActionsViewModel import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.RemoveNodeTagsUseCase @@ -40,7 +38,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class AddRemoveTagsViewModel @AssistedInject constructor( - @Assisted val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: AddRemoveTagsNavArgs, private val getAllTagsUseCase: GetAllTagsUseCase, private val updateNodeTagsUseCase: UpdateNodeTagsUseCase, private val removeNodeTagsUseCase: RemoveNodeTagsUseCase, @@ -48,10 +46,9 @@ class AddRemoveTagsViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): AddRemoveTagsViewModel + fun create(navArgs: AddRemoveTagsNavArgs): AddRemoveTagsViewModel } - private val navArgs: AddRemoveTagsNavArgs = AddRemoveTagsScreenDestination.argsFrom(savedStateHandle) private val initialTags: Set = navArgs.tags.toSet() private val disallowedChars = setOf(",", ";", "/", "\\", "\"", "\'", "<", ">") diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt index bcc52e9b80f..904e3fe047f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt @@ -45,9 +45,6 @@ import com.wire.android.feature.cells.ui.common.LoadingScreen import com.wire.android.feature.cells.ui.versioning.download.DownloadState import com.wire.android.feature.cells.ui.versioning.restore.RestoreDialogState import com.wire.android.feature.cells.ui.versioning.restore.RestoreNodeVersionConfirmationDialog -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.colorsScheme @@ -64,15 +61,11 @@ import com.wire.android.util.openDownloadFolder import com.wire.android.util.ui.toUIText import kotlinx.coroutines.launch -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = VersionHistoryNavArgs::class, -) @Composable -fun VersionHistoryScreen( - navigator: WireNavigator, +internal fun VersionHistoryRouteScreen( + onNavigateBack: () -> Unit, modifier: Modifier = Modifier, - versionHistoryViewModel: VersionHistoryViewModel = versionHistoryViewModel() + versionHistoryViewModel: VersionHistoryViewModel, ) { val optionsBottomSheetState = rememberWireModalSheetState>() val snackbarHostState = LocalSnackbarHostState.current @@ -89,7 +82,7 @@ fun VersionHistoryScreen( fileName = versionHistoryViewModel.fileName, versionHistoryState = versionHistoryViewModel.versionHistoryState, restoreDialogState = versionHistoryViewModel.restoreDialogState.value, - navigateBack = { navigator.navigateBack() }, + navigateBack = onNavigateBack, optionsBottomSheetState = optionsBottomSheetState, restoreVersion = { versionHistoryViewModel.restoreVersion() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt index 9df15cc58c5..7492f083562 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt @@ -19,10 +19,8 @@ package com.wire.android.feature.cells.ui.versioning import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.versioning.download.DownloadState @@ -55,7 +53,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter class VersionHistoryViewModel @AssistedInject constructor( - @Assisted private val savedStateHandle: SavedStateHandle, + @Assisted private val navArgs: VersionHistoryNavArgs, private val getNodeVersionsUseCase: GetNodeVersionsUseCase, private val fileSizeFormatter: FileSizeFormatter, private val restoreNodeVersionUseCase: RestoreNodeVersionUseCase, @@ -68,11 +66,9 @@ class VersionHistoryViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): VersionHistoryViewModel + fun create(navArgs: VersionHistoryNavArgs): VersionHistoryViewModel } - private val navArgs: VersionHistoryNavArgs = VersionHistoryScreenDestination.argsFrom(savedStateHandle) - val fileName = navArgs.fileName var versionHistoryState: MutableState = mutableStateOf(VersionHistoryState.Idle) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt deleted file mode 100644 index 3bc5520504e..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.cells.ui.videoplayer - -import androidx.compose.runtime.Composable -import com.wire.android.mediaplayer.VideoPlayer -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.cells.WireCellsDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation - -/** - * Cells navigation entry point for the shared [VideoPlayer]. Reads the destination's [VideoViewerNavArgs] - * and delegates rendering + playback to the reusable player in `core:media-player`. - */ -@WireCellsDestination( - style = PopUpNavigationAnimation::class, - navArgs = VideoViewerNavArgs::class, -) -@Composable -fun VideoPlayerScreen( - navigator: WireNavigator, - navArgs: VideoViewerNavArgs, -) { - VideoPlayer( - localPath = navArgs.localPath, - contentUrl = navArgs.contentUrl, - fileName = navArgs.fileName, - onNavigateBack = navigator::navigateBack, - ) -} diff --git a/features/cells/src/main/java/com/wire/android/navigation/annotation/features/cells/WireCellsDestination.kt b/features/cells/src/main/java/com/wire/android/navigation/annotation/features/cells/WireCellsDestination.kt deleted file mode 100644 index 4d3564a4106..00000000000 --- a/features/cells/src/main/java/com/wire/android/navigation/annotation/features/cells/WireCellsDestination.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.annotation.features.cells - -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.Destination.Companion.COMPOSABLE_NAME -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.wire.android.feature.cells.navigation.CellsNavGraph -import com.wire.android.navigation.wrapper.TabletDialogWrapper -import com.wire.android.navigation.wrapper.WaitUntilTransitionEndsWrapper -import kotlin.reflect.KClass - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireCellsDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) diff --git a/features/cells/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt b/features/cells/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt deleted file mode 100644 index a6a864d876f..00000000000 --- a/features/cells/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.config - -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext - -/** - * This extension provides a way to mock navigation arguments getters. - * It can be used to mock getting navigation arguments from savedStateHandle, like: savedStateHandle.navArgs() - * or to get arguments from the specific Destination object, like: ExampleScreenDestination.argsFrom(savedStateHandle). - * - * Add this JUnit 5 extension to your test class using - * @JvmField - * @RegisterExtension - * val navigationTestExtension = NavigationTestExtension() - * - * or: - * - * Annotating the class with - * @ExtendWith(NavigationTestExtension::class) - */ -class NavigationTestExtension : BeforeEachCallback, AfterEachCallback { - override fun beforeEach(context: ExtensionContext?) { - mockkStatic("com.ramcosta.composedestinations.generated.cells.NavArgsGettersKt") - } - - override fun afterEach(context: ExtensionContext?) { - unmockkStatic("com.ramcosta.composedestinations.generated.cells.NavArgsGettersKt") - } -} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3SourceTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3SourceTest.kt new file mode 100644 index 00000000000..38e03bffa1c --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3SourceTest.kt @@ -0,0 +1,76 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.cells.navigation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import java.io.File + +class CellsNavigation3SourceTest { + + @Test + fun givenCellsEntryProvider_whenRegistrationsAreCounted_thenAllDestinationsAreCovered() { + val source = sourceFile("CellsNavigation3Entries.kt").readText() + val registrationCount = Regex("""wireEntry<""").findAll(source).count() + + assertEquals(CellsNavigation3Contribution.ROUTE_REGISTRATION_COUNT, registrationCount) + } + + @Test + fun givenKmpRouteContracts_whenInspected_thenTheyDoNotDependOnAndroidOrLegacyNavigation() { + val source = sourceFile("CellsNavigation3.kt").readText() + + assertFalse(source.contains("import android.")) + assertFalse(source.contains("import com.ramcosta")) + assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("CellFilesNavArgs")) + } + + @Test + fun givenNavigation3RuntimeFiles_whenInspected_thenTheyDoNotDependOnComposeDestinationsOrNav2() { + listOf( + sourceFile("CellsNavigation3Entries.kt"), + sourceFile("CellsNavigation3Renderer.kt"), + ).forEach { file -> + val source = file.readText() + assertFalse(source.contains("com.ramcosta"), file.name) + assertFalse(source.contains("WireNavigator"), file.name) + assertFalse(source.contains("com.wire.android.navigation.NavigationCommand"), file.name) + } + } + + @Test + fun givenCellsProductionSources_whenInspected_thenLegacyNavigationIsAbsent() { + val navigationDirectory = requireNotNull(sourceFile("CellsNavigation3.kt").parentFile) + val cellsSourceDirectory = requireNotNull(navigationDirectory.parentFile) + val mainSources = cellsSourceDirectory + .walkTopDown() + .filter(File::isFile) + .filter { it.extension == "kt" } + + mainSources.forEach { file -> + val source = file.readText() + assertFalse(source.contains("com.ramcosta"), file.path) + assertFalse(source.contains("WireNavigator"), file.path) + assertFalse(source.contains("WireCellsDestination"), file.path) + assertFalse(source.contains("com.wire.android.navigation.NavigationCommand"), file.path) + } + } + + private fun sourceFile(name: String): File = + generateSequence(File(System.getProperty("user.dir"))) { current -> + current.parentFile + } + .map { File(it, "src/main/java/com/wire/android/feature/cells/navigation/$name") } + .firstOrNull(File::isFile) + ?: error("Could not find $name from ${System.getProperty("user.dir")}") +} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt new file mode 100644 index 00000000000..1a4502428fb --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/navigation/CellsNavigation3Test.kt @@ -0,0 +1,91 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.cells.navigation + +import com.wire.android.feature.cells.ui.CellFilesNavArgs +import com.wire.navigation.WireSessionId +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CellsNavigation3Test { + + @Test + fun givenCellsRoutes_whenRouteIdsAreCollected_thenEveryRegistrationIsUnique() { + val sessionId = WireSessionId("session", "wire.com") + val routes = listOf( + ConversationFilesRoute(sessionId, CellsFilesArguments()), + ConversationFilesSlideRoute(sessionId, CellsFilesArguments()), + RecycleBinRoute(sessionId, CellsFilesArguments(isRecycleBin = true)), + CreateFolderRoute(sessionId, null), + CreateFileRoute(sessionId, "parent", CellsFileType.DOCUMENT), + MoveToFolderRoute(sessionId, "current", "node", "uuid"), + PublicLinkRoute(sessionId, "asset", "name", null, false), + PublicLinkExpirationRoute(sessionId, "link", null), + PublicLinkPasswordRoute(sessionId, "link", false), + RenameNodeRoute(sessionId, "uuid", "path", false, "name"), + AddRemoveTagsRoute(sessionId, "uuid", emptyList()), + VersionHistoryRoute(sessionId, "uuid", "name"), + CellImageViewerRoute(sessionId), + VideoPlayerRoute(sessionId), + AudioPlayerRoute(sessionId), + SearchRoute(sessionId), + ) + + assertEquals(CellsNavigation3Contribution.ROUTE_REGISTRATION_COUNT, routes.size) + assertEquals(routes.size, routes.map { it.routeId }.distinct().size) + assertTrue(routes.all { it.sessionId == sessionId }) + } + + @Test + fun givenCellsResultTypes_whenCollected_thenContractsAreUnique() { + val ids = CellsNavigation3Contribution.resultTypes.map { it.contract.id } + + assertEquals(ids.size, ids.distinct().size) + assertEquals(2, ids.size) + } + + @Test + fun givenScreenFilesArguments_whenMappedRoundTrip_thenValuesArePreserved() { + val screenArgs = CellFilesNavArgs( + conversationId = "id@wire.com", + screenTitle = "Title / with spaces", + isRecycleBin = true, + breadcrumbs = arrayOf("root", "A/B"), + parentFolderUuid = null, + isSearchByDefaultActive = true, + ) + + assertEquals(screenArgs, screenArgs.toNavigation3().toScreenArgs()) + } + + @Test + fun givenCellsResultRequests_whenInspectingRenderer_thenIdsSurviveRecreation() { + val source = sourceFile().readText() + + assertTrue("rememberSaveable(route.entryId.value, \"boolean-result\")" in source) + assertTrue("rememberSaveable(route.entryId.value, \"expiration-result\")" in source) + assertTrue("requestIdValue: String?" in source) + assertTrue("requestIdValue?.let(::WireNavResultRequestId)" in source) + assertFalse("mutableStateOf" in source) + } + + private fun sourceFile(): File { + val root = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } + .first { File(it, "features/cells/src/main").isDirectory } + return File( + root, + "features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt", + ) + } +} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index 7d6d181957c..9b70535e096 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -17,22 +17,17 @@ */ package com.wire.android.feature.cells.ui -import androidx.lifecycle.SavedStateHandle import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingData import androidx.paging.testing.asSnapshot import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination -import com.wire.android.config.NavigationTestExtension import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.model.toUiModel +import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.feature.cells.ui.search.SearchNavArgs -import com.wire.android.feature.cells.ui.search.sort.SortBy -import com.wire.android.feature.cells.ui.search.sort.SortingCriteria import com.wire.android.feature.cells.util.FileHelper import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.model.Node @@ -57,7 +52,6 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -75,10 +69,8 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith import java.io.File -@ExtendWith(NavigationTestExtension::class) class CellViewModelTest { private companion object { @@ -415,63 +407,12 @@ class CellViewModelTest { } } - @Test - fun `GIVEN AllFiles context WHEN setSortBy called with Name THEN sortingCriteria changes to ByName AtoZ`() = runTest { - val (_, viewModel) = Arrangement() - .withLoadSuccess() - .arrange() - - assertEquals(SortingCriteria.ByDate.NewestFirst, viewModel.sortingCriteria.value) - - viewModel.setSortBy(SortBy.Name) - - assertEquals(SortingCriteria.ByName.AtoZ, viewModel.sortingCriteria.value) - } - - @Test - fun `GIVEN AllFiles context WHEN setSorting called THEN sortingCriteria updates`() = runTest { - val (_, viewModel) = Arrangement() - .withLoadSuccess() - .arrange() - - viewModel.setSorting(SortingCriteria.ByName.ZtoA) - - assertEquals(SortingCriteria.ByName.ZtoA, viewModel.sortingCriteria.value) - } - - @Test - fun `GIVEN same sortBy WHEN setSortBy called with same criteria THEN sortingCriteria unchanged`() = runTest { - val (_, viewModel) = Arrangement() - .withLoadSuccess() - .arrange() - - val initial = viewModel.sortingCriteria.value - viewModel.setSortBy(SortBy.Modified) - - assertEquals(initial, viewModel.sortingCriteria.value) - } - - @Test - fun `GIVEN conversation context WHEN setSortBy called with Size THEN sortingCriteria changes to BySize SmallestFirst`() = runTest { - val (_, viewModel) = Arrangement() - .withLoadSuccess() - .withConversationId("conversationId") - .arrange() - - assertEquals(SortingCriteria.FoldersFirst, viewModel.sortingCriteria.value) - - viewModel.setSortBy(SortBy.Size) - - assertEquals(SortingCriteria.BySize.SmallestFirst, viewModel.sortingCriteria.value) - } - private class Arrangement( private var conversationId: String? = null, private var inAppImageViewerEnabled: Boolean = false, ) { - @MockK - lateinit var savedStateHandle: SavedStateHandle + private var searchNavArgs: SearchNavArgs? = null @MockK lateinit var getCellFilesPagedUseCase: GetPaginatedFilesFlowUseCase @@ -530,16 +471,6 @@ class CellViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) - mockkObject(ConversationFilesScreenDestination) - mockkObject(SearchScreenDestination) - every { SearchScreenDestination.argsFrom(savedStateHandle) } throws RuntimeException("Not a search screen") - every { ConversationFilesScreenDestination.argsFrom(savedStateHandle) } returns CellFilesNavArgs( - conversationId = conversationId - ) - - every { savedStateHandle.get(any()) } returns conversationId - every { savedStateHandle.get("conversationId") } returns conversationId - coEvery { isCellAvailableUseCase.invoke() } returns true.right() every { observeOfflineFiles() } returns flowOf(emptyList()) @@ -602,18 +533,14 @@ class CellViewModelTest { fun withConversationId(conversationId: String) = apply { this.conversationId = conversationId - every { ConversationFilesScreenDestination.argsFrom(savedStateHandle) } returns CellFilesNavArgs( - conversationId = conversationId - ) - every { savedStateHandle.get(any()) } returns conversationId - every { savedStateHandle.get("conversationId") } returns conversationId } fun withSearchScreenArgsOnly() = apply { - every { SearchScreenDestination.argsFrom(savedStateHandle) } returns SearchNavArgs( - conversationId = conversationId + searchNavArgs = SearchNavArgs( + conversationId = conversationId, + screenType = DriveSearchScreenType.SHARED_DRIVE, + parentRoute = null, ) - every { ConversationFilesScreenDestination.argsFrom(savedStateHandle) } throws RuntimeException("Not a files screen") } fun arrange(): Pair { @@ -639,7 +566,8 @@ class CellViewModelTest { ) return this to CellViewModel( - savedStateHandle = savedStateHandle, + navArgs = CellFilesNavArgs(conversationId = conversationId), + searchNavArgs = searchNavArgs, getCellFilesPaged = getCellFilesPagedUseCase, deleteCellAsset = deleteCellAssetUseCase, restoreNodeFromRecycleBinUseCase = restoreNodeFromRecycleBinUseCase, diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt index bc8a6f18809..f6c10627226 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt @@ -20,17 +20,13 @@ package com.wire.android.feature.cells.ui.audioplayer import android.content.Context import android.media.MediaPlayer import android.net.Uri -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import io.mockk.Runs import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.mockkConstructor -import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll @@ -43,7 +39,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class) class AudioPlayerViewModelTest { @AfterEach @@ -278,7 +274,6 @@ class AudioPlayerViewModelTest { private class Arrangement { val context = mockk(relaxed = true) - val savedStateHandle = mockk(relaxed = true) val fileUri = mockk() val contentUri = mockk() @@ -289,8 +284,6 @@ class AudioPlayerViewModelTest { private var navArgs = AudioPlayerNavArgs(localPath = "/tmp/audio.mp3") init { - mockkObject(CellAudioPlayerScreenDestination) - mockkStatic(Uri::class) every { Uri.fromFile(any()) } returns fileUri every { Uri.parse(any()) } returns contentUri @@ -341,8 +334,12 @@ class AudioPlayerViewModelTest { } fun arrange(): Pair { - every { CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) } returns navArgs - return this to AudioPlayerViewModel(context, savedStateHandle) + return this to AudioPlayerViewModel( + context = context, + localPath = navArgs.localPath, + contentUrl = navArgs.contentUrl, + fileName = navArgs.fileName, + ) } } } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt index 6fde9f7bd20..5c9138bc2cc 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.create.file -import androidx.lifecycle.SavedStateHandle import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.kalium.cells.domain.usecase.create.CreateDocumentFileUseCase import com.wire.kalium.cells.domain.usecase.create.CreatePresentationFileUseCase @@ -27,7 +26,6 @@ import com.wire.kalium.common.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -215,9 +213,6 @@ class CreateFileViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var createPresentationFileUseCase: CreatePresentationFileUseCase @@ -228,15 +223,15 @@ class CreateFileViewModelTest { lateinit var createSpreadsheetFileUseCase: CreateSpreadsheetFileUseCase private val testUuid = "test-uuid" + private var fileType = FileType.DOCUMENT init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.get("uuid") } returns testUuid } private val viewModel by lazy { CreateFileViewModel( - savedStateHandle = savedStateHandle, + navArgs = CreateFileScreenNavArgs(testUuid, fileType), createPresentationFileUseCase = createPresentationFileUseCase, createDocumentFileUseCase = createDocumentFileUseCase, createSpreadsheetFileUseCase = createSpreadsheetFileUseCase, @@ -244,7 +239,7 @@ class CreateFileViewModelTest { } fun withFileTypeReturning(result: FileType) = apply { - every { savedStateHandle.get("fileType") } returns result + fileType = result } fun withCreateDocumentFileUseCaseReturning(result: Either) = apply { diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt index 25509aba7e4..54a37efe0e5 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.create.folder -import androidx.lifecycle.SavedStateHandle import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.kalium.cells.domain.usecase.create.CreateFolderUseCase import com.wire.kalium.common.error.CoreFailure @@ -25,7 +24,6 @@ import com.wire.kalium.common.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -138,9 +136,6 @@ class CreateFolderViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var createFolderUseCase: CreateFolderUseCase @@ -148,12 +143,11 @@ class CreateFolderViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.get("uuid") } returns testUuid } private val viewModel by lazy { CreateFolderViewModel( - savedStateHandle = savedStateHandle, + navArgs = CreateFolderScreenNavArgs(testUuid), createFolderUseCase = createFolderUseCase, ) } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModelTest.kt index 371c911964b..542ff93c9ab 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.movetofolder -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.feature.cells.ui.model.toUiModel import com.wire.kalium.cells.domain.model.Node @@ -27,7 +26,6 @@ import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher @@ -187,30 +185,19 @@ class MoveToFolderViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getFoldersUseCase: GetFoldersUseCase @MockK lateinit var moveNodeUseCase: MoveNodeUseCase - private val navArgsMap = mutableMapOf( - "currentPath" to CURRENT_PATH, - "nodeToMovePath" to NODE_TO_MOVE_PATH, - "uuid" to UUID, - "breadcrumbs" to BREADCRUMBS, - ) - init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.get(any()) } answers { navArgsMap[firstArg()] } } private val viewModel by lazy { MoveToFolderViewModel( - savedStateHandle = savedStateHandle, + navArgs = MoveToFolderNavArgs(CURRENT_PATH, NODE_TO_MOVE_PATH, UUID, BREADCRUMBS), getFoldersUseCase = getFoldersUseCase, moveNodeUseCase = moveNodeUseCase, ) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModelTest.kt index 76147d7d157..a4707b65def 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/PublicLinkViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.publiclink -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.feature.cells.util.FileHelper import com.wire.kalium.cells.domain.model.PublicLink @@ -30,7 +29,6 @@ import com.wire.kalium.common.functional.right import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -199,9 +197,6 @@ class PublicLinkViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var createPublicLinkUseCase: CreatePublicLinkUseCase @@ -214,24 +209,18 @@ class PublicLinkViewModelTest { @MockK lateinit var fileHelper: FileHelper - private val navArgsMap = mutableMapOf( - "assetId" to "assetId", - "fileName" to "fileName", - "publicLinkId" to "publicLinkId", - "isFolder" to false, - ) + private var publicLinkId: String? = "publicLinkId" init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.get(any()) } answers { navArgsMap[firstArg()] } } fun withPublicLink() = apply { - navArgsMap["publicLinkId"] = "publicLinkId" + publicLinkId = "publicLinkId" } fun withoutPublicLink() = apply { - navArgsMap["publicLinkId"] = null + publicLinkId = null } fun withLoadSuccess() = apply { @@ -260,7 +249,7 @@ class PublicLinkViewModelTest { fun arrange(): Pair { return this to PublicLinkViewModel( - savedStateHandle = savedStateHandle, + navArgs = PublicLinkNavArgs("assetId", "fileName", publicLinkId, false), createPublicLink = createPublicLinkUseCase, getPublicLinkUseCase = getPublicLinkUseCase, deletePublicLinkUseCase = deletePublicLinkUseCase, diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModelTest.kt index 8500d91c569..cf5e4af8f01 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreenViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.publiclink.settings.expiration -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.ui.common.datetime.TimePickerResult import com.wire.kalium.cells.domain.usecase.publiclink.SetPublicLinkExpirationUseCase @@ -298,12 +297,7 @@ class PublicLinkExpirationScreenViewModelTest { fun arrange(): Pair { return this to PublicLinkExpirationScreenViewModel( setExpiration = setExpiration, - savedStateHandle = SavedStateHandle( - mapOf( - "linkUuid" to "public_link_uuid", - "expiresAt" to expiresAt - ) - ) + navArgs = PublicLinkExpirationScreenNavArgs("public_link_uuid", expiresAt), ) } } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModelTest.kt index a13a849f008..2ccb3accca1 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreenViewModelTest.kt @@ -18,7 +18,6 @@ package com.wire.android.feature.cells.ui.publiclink.settings.password import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.kalium.cells.domain.model.PublicLink import com.wire.kalium.cells.domain.usecase.publiclink.CreatePublicLinkPasswordUseCase @@ -371,18 +370,14 @@ class PublicLinkPasswordScreenViewModelTest { private class Arrangement { - private val navArgsMap = mutableMapOf( - "linkUuid" to testLink.uuid, - "passwordEnabled" to false, - ) + private var passwordEnabled = false init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.get(any()) } answers { navArgsMap[firstArg()] } } fun withPasswordEnabled(enabled: Boolean) = apply { - navArgsMap["passwordEnabled"] = enabled + passwordEnabled = enabled } fun withPasswordRemoveSuccess() = apply { @@ -423,9 +418,6 @@ class PublicLinkPasswordScreenViewModelTest { @MockK lateinit var getLocalPassword: GetPublicLinkPasswordUseCase - @MockK - lateinit var savedStateHandle: SavedStateHandle - fun arrange(): Pair { every { generateRandomPassword() } returns randomPassword @@ -435,7 +427,7 @@ class PublicLinkPasswordScreenViewModelTest { createPassword = createPassword, updatePassword = updatePassword, getPublicLinkPassword = getLocalPassword, - savedStateHandle = savedStateHandle + navArgs = PublicLinkPasswordNavArgs(testLink.uuid, passwordEnabled), ) } } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt index 1eb828d117c..b8a37bcf93f 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt @@ -17,9 +17,7 @@ */ package com.wire.android.feature.cells.ui.rename -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.cells.navArgs import com.wire.android.feature.cells.ui.common.FileNameError import com.wire.kalium.cells.domain.usecase.RenameNodeFailure import com.wire.kalium.cells.domain.usecase.RenameNodeUseCase @@ -29,7 +27,6 @@ import com.wire.kalium.common.functional.left import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher @@ -174,31 +171,16 @@ class RenameNodeViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var renameNodeUseCase: RenameNodeUseCase - init { - - MockKAnnotations.init(this, relaxUnitFun = true) + private var nodeName = NODE_NAME - every { savedStateHandle.navArgs() } returns RenameNodeNavArgs( - uuid = UUID, - currentPath = CURRENT_PATH, - nodeName = NODE_NAME, - isFolder = true - ) - every { savedStateHandle.get("uuid") } returns UUID - every { savedStateHandle.get("currentPath") } returns CURRENT_PATH - every { savedStateHandle.get("isFolder") } returns false - every { savedStateHandle.get("nodeName") } returns NODE_NAME - } + init { MockKAnnotations.init(this, relaxUnitFun = true) } private val viewModel by lazy { RenameNodeViewModel( - savedStateHandle = savedStateHandle, + navArgs = RenameNodeNavArgs(UUID, CURRENT_PATH, false, nodeName), renameNodeUseCase = renameNodeUseCase, ) } @@ -207,7 +189,7 @@ class RenameNodeViewModelTest { coEvery { renameNodeUseCase(any(), any(), any()) } returns result } fun withNodeNameReturning(name: String) = apply { - every { savedStateHandle.get("nodeName") } returns name + nodeName = name } fun arrange() = this to viewModel diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt index c119002f775..08e547da06a 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.search -import androidx.lifecycle.SavedStateHandle import androidx.paging.PagingData import com.wire.android.feature.cells.ui.CellFileLocalPathCache import com.wire.android.feature.cells.ui.search.filter.data.FilterConversationUi @@ -38,7 +37,6 @@ import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow @@ -83,24 +81,10 @@ class SearchScreenViewModelTest { private val sharedPathCache = CellFileLocalPathCache() - private lateinit var savedStateHandle: SavedStateHandle - @BeforeEach fun setup() { Dispatchers.setMain(dispatcher) - val navArgsMap = mapOf( - "conversationId" to CONVERSATION_ID, - "screenType" to DriveSearchScreenType.SHARED_DRIVE - ) - - savedStateHandle = mockk(relaxed = true) - - every { savedStateHandle.get(any()) } answers { - val key = firstArg() - navArgsMap[key] - } - MockKAnnotations.init(this) coEvery { getAllTagsUseCase() } returns Either.Right(mockTags) @@ -154,18 +138,8 @@ class SearchScreenViewModelTest { @Test fun `given parentRoute in nav args, when ViewModel is created, then parentRoute is exposed`() = runTest { val expectedRoute = "app/global_cells_screen" - val navArgsWithParentRoute = mapOf( - "conversationId" to CONVERSATION_ID, - "screenType" to DriveSearchScreenType.SHARED_DRIVE, - "parentRoute" to expectedRoute, - ) - val savedStateHandleWithParentRoute = mockk(relaxed = true) - every { savedStateHandleWithParentRoute.get(any()) } answers { - navArgsWithParentRoute[firstArg()] - } - val viewModel = SearchScreenViewModel( - savedStateHandle = savedStateHandleWithParentRoute, + navArgs = SearchNavArgs(CONVERSATION_ID, DriveSearchScreenType.SHARED_DRIVE, expectedRoute), getAllTagsUseCase = getAllTagsUseCase, getCellFilesPaged = getCellFilesPaged, getOwners = getOwners, @@ -365,7 +339,7 @@ class SearchScreenViewModelTest { private fun createViewModel(): SearchScreenViewModel { return SearchScreenViewModel( - savedStateHandle = savedStateHandle, + navArgs = SearchNavArgs(CONVERSATION_ID, DriveSearchScreenType.SHARED_DRIVE), getAllTagsUseCase = getAllTagsUseCase, getCellFilesPaged = getCellFilesPaged, getOwners = getOwners, @@ -376,14 +350,8 @@ class SearchScreenViewModelTest { } private fun createViewModelWithScreenType(screenType: DriveSearchScreenType): SearchScreenViewModel { - val navArgsMap = mapOf( - "conversationId" to CONVERSATION_ID, - "screenType" to screenType, - ) - val handle = mockk(relaxed = true) - every { handle.get(any()) } answers { navArgsMap[firstArg()] } return SearchScreenViewModel( - savedStateHandle = handle, + navArgs = SearchNavArgs(CONVERSATION_ID, screenType), getAllTagsUseCase = getAllTagsUseCase, getCellFilesPaged = getCellFilesPaged, getOwners = getOwners, diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt index ff78972d44a..fd3166b2ab8 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt @@ -18,7 +18,6 @@ package com.wire.android.feature.cells.ui.tags import androidx.compose.foundation.text.input.setTextAndSelectAll -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModelTest.Companion.UUID import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase @@ -29,7 +28,6 @@ import com.wire.kalium.common.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher @@ -293,9 +291,6 @@ class AddRemoveTagsViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - @MockK lateinit var getAllTagsUseCase: GetAllTagsUseCase @@ -307,8 +302,6 @@ class AddRemoveTagsViewModelTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { savedStateHandle.get("uuid") } returns UUID - every { savedStateHandle.get>("tags") } returns ArrayList() } fun withGetAllTagsUseCaseReturning(result: Either>) = apply { @@ -327,7 +320,7 @@ class AddRemoveTagsViewModelTest { // Create a new ViewModel instance every time arrange() is called. // This prevents state from leaking between tests. val viewModel = AddRemoveTagsViewModel( - savedStateHandle = savedStateHandle, + navArgs = AddRemoveTagsNavArgs(UUID, arrayListOf()), getAllTagsUseCase = getAllTagsUseCase, updateNodeTagsUseCase = updateNodeTagsUseCase, removeNodeTagsUseCase = removeNodeTagsUseCase, diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt index 866861a18ae..63d2ebcbd49 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.versioning -import androidx.lifecycle.SavedStateHandle import com.wire.android.config.TestDispatcherProvider import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.edit.OnlineEditor @@ -83,7 +82,6 @@ class VersionHistoryViewModelTest { @Test fun givenViewModel_whenItInits_thenIsFetchingStateIsManagedCorrectly() = runTest { val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .arrange() @@ -100,7 +98,6 @@ class VersionHistoryViewModelTest { val twoDaysAgo = today.minusDays(2) val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(versionsFromApi)) .withFileSizeFormatter() .arrange() @@ -146,7 +143,6 @@ class VersionHistoryViewModelTest { @Test fun givenApiFailure_whenViewModelInits_thenVersionListIsEmpty() = runTest { val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Left(CoreFailure.MissingClientRegistration)) .arrange() @@ -161,7 +157,6 @@ class VersionHistoryViewModelTest { // GIVEN an initial state where the dialog is not visible val testVersionId = "version-id-12345" val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .arrange() @@ -183,7 +178,6 @@ class VersionHistoryViewModelTest { fun givenDialogIsVisible_whenHideRestoreConfirmationDialogIsCalled_thenStateIsHiddenAndReset() = runTest { // GIVEN an initial state where the dialog is visible and has data val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .arrange() @@ -211,7 +205,6 @@ class VersionHistoryViewModelTest { // GIVEN the restore use case will succeed val testVersionId = "version-to-restore" val (arrangement, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withRestoreNodeVersionReturning(Unit.right()) .arrange() @@ -239,7 +232,6 @@ class VersionHistoryViewModelTest { // GIVEN val testVersionId = "version-to-restore" val (arrangement, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withRestoreNodeVersionReturning(Either.Left(CoreFailure.MissingClientRegistration)) .arrange() @@ -264,7 +256,6 @@ class VersionHistoryViewModelTest { fun givenVersionExistsAndUseCaseSucceeds_whenDownloadVersionIsCalled_thenStateBecomesDownloaded() = runTest { // GIVEN a version exists and all dependencies will succeed val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withDownloadVersionReturning(shouldSucceed = true, true) .withGetNodeVersionReturning(Either.Right(versionsFromApi)) .withFileSizeFormatter() @@ -288,7 +279,6 @@ class VersionHistoryViewModelTest { fun givenDownloadUseCaseFails_whenDownloadVersionIsCalled_thenStateBecomesFailed() = runTest { // GIVEN a version exists but the download use case will fail val (_, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withDownloadVersionReturning(shouldSucceed = false) .withFileSizeFormatter() @@ -313,7 +303,6 @@ class VersionHistoryViewModelTest { fun givenFileCreationFails_whenDownloadVersionIsCalled_thenStateBecomesFailed() = runTest { // GIVEN a version exists but the file helper returns null (cannot create file) val (arrangement, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withFileSizeFormatter() .withFileCreationFailure() @@ -337,7 +326,6 @@ class VersionHistoryViewModelTest { @Test fun givenVersionDoesNotExist_whenDownloadVersionIsCalled_thenStateBecomesFailed() = runTest { val (arrangement, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withFileSizeFormatter() .withFileCreationFailure() @@ -358,7 +346,6 @@ class VersionHistoryViewModelTest { // Given val expectedUrl = "https://example.com/editor" val (arrangement, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withGetEditorUrlReturning(expectedUrl.right()) .withOnlineEditor() @@ -377,7 +364,6 @@ class VersionHistoryViewModelTest { fun givenGetEditorUrlFails_whenOpenOnlineEditorIsCalled_thenEditorIsNotOpened() = runTest { // Given val (arrangement, viewModel) = Arrangement() - .withSavedStateHandleReturning() .withGetNodeVersionReturning(Either.Right(emptyList())) .withGetEditorUrlReturning(Either.Left(CoreFailure.MissingClientRegistration)) .withOnlineEditor() @@ -394,7 +380,6 @@ class VersionHistoryViewModelTest { private class Arrangement { - val savedStateHandle: SavedStateHandle = mockk(relaxed = true) val getNodeVersionsUseCase: GetNodeVersionsUseCase = mockk() val fileSizeFormatter: FileSizeFormatter = mockk() val restoreNodeVersionUseCase: RestoreNodeVersionUseCase = mockk() @@ -406,16 +391,6 @@ class VersionHistoryViewModelTest { private val testNodeUuid = "test-node-uuid" - init { - every { savedStateHandle.get("uuid") } returns "test-node-uuid" - every { savedStateHandle.get("fileName") } returns "file-name" - } - - fun withSavedStateHandleReturning() = apply { - every { savedStateHandle.get("uuid") } returns testNodeUuid - every { savedStateHandle.get("fileName") } returns "file-name" - } - fun withGetNodeVersionReturning(returnValue: Either>) = apply { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns returnValue } @@ -470,7 +445,7 @@ class VersionHistoryViewModelTest { fun arrange(): Pair { val viewModel = VersionHistoryViewModel( - savedStateHandle = savedStateHandle, + navArgs = VersionHistoryNavArgs(testNodeUuid, "file-name"), getNodeVersionsUseCase = getNodeVersionsUseCase, fileSizeFormatter = fileSizeFormatter, restoreNodeVersionUseCase = restoreNodeVersionUseCase, diff --git a/features/cells/stability/cells-debug.stability b/features/cells/stability/cells-debug.stability index 426c1be4730..d59997ca858 100644 --- a/features/cells/stability/cells-debug.stability +++ b/features/cells/stability/cells-debug.stability @@ -5,107 +5,31 @@ // ./gradlew :cells:stabilityDump @Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination.Content(): kotlin.Unit +internal fun com.wire.android.feature.cells.navigation.CellsNavigation3RouteScreen(route: com.wire.android.feature.cells.navigation.CellsRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, onExitCells: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - onExitCells: STABLE (function type) @Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkExpirationScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkPasswordScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.RecycleBinScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination.Content(): kotlin.Unit +private fun com.wire.android.feature.cells.navigation.consumeNavigation3Result(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, requestIdValue: kotlin.String?, resultType: com.wire.android.navigation.navigation3.WireNavigation3ResultType, onConsumed: kotlin.Function0, onResult: kotlin.Function1): kotlin.Unit skippable: true restartable: true params: + - runtime: STABLE (marked @Stable or @Immutable) + - requestIdValue: STABLE (class with no mutable properties) + - resultType: STABLE (class with no mutable properties) + - onConsumed: STABLE (function type) + - onResult: STABLE (function type) @Composable -public fun com.wire.android.feature.cells.ui.AllFilesScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit +public fun com.wire.android.feature.cells.ui.AllFilesScreen(navigationActions: com.wire.android.feature.cells.ui.AllFilesNavigationActions, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - navigationActions: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -200,21 +124,21 @@ private fun com.wire.android.feature.cells.ui.ContentList(cellNodes: androidx.pa - showViewerOnlyIcon: STABLE (primitive type) @Composable -public fun com.wire.android.feature.cells.ui.ConversationFilesScreen(navigator: com.wire.android.navigation.WireNavigator, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.ConversationFilesRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - navigation: RUNTIME (requires runtime check) - animatedVisibilityScope: RUNTIME (requires runtime check) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable -internal fun com.wire.android.feature.cells.ui.ConversationFilesScreenContent(animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, navigator: com.wire.android.navigation.WireNavigator, currentNodeUuid: kotlin.String?, isSearchResult: kotlin.Boolean, actions: kotlinx.coroutines.flow.Flow, pagingListItems: androidx.paging.compose.LazyPagingItems, menu: kotlinx.coroutines.flow.SharedFlow, sendIntent: kotlin.Function1, isRefreshing: androidx.compose.runtime.State, onRefresh: kotlin.Function0, retryEditNodeError: kotlin.Function1, modifier: androidx.compose.ui.Modifier, isDeleteInProgress: kotlin.Boolean, screenTitle: kotlin.String?, isRecycleBin: kotlin.Boolean, isRestoreInProgress: kotlin.Boolean, isOnline: kotlin.Boolean, breadcrumbs: kotlin.Array?, fileReadyFlow: kotlinx.coroutines.flow.Flow, sortingCriteria: com.wire.android.feature.cells.ui.search.sort.SortingCriteria, onSortByClicked: kotlin.Function1, onSortOrderClicked: kotlin.Function1): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.ConversationFilesScreenContent(animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, currentNodeUuid: kotlin.String?, isSearchResult: kotlin.Boolean, actions: kotlinx.coroutines.flow.Flow, pagingListItems: androidx.paging.compose.LazyPagingItems, menu: kotlinx.coroutines.flow.SharedFlow, sendIntent: kotlin.Function1, isRefreshing: androidx.compose.runtime.State, onRefresh: kotlin.Function0, retryEditNodeError: kotlin.Function1, modifier: androidx.compose.ui.Modifier, isDeleteInProgress: kotlin.Boolean, screenTitle: kotlin.String?, isRecycleBin: kotlin.Boolean, isRestoreInProgress: kotlin.Boolean, isOnline: kotlin.Boolean, breadcrumbs: kotlin.Array?, fileReadyFlow: kotlinx.coroutines.flow.Flow, sortingCriteria: com.wire.android.feature.cells.ui.search.sort.SortingCriteria, onSortByClicked: kotlin.Function1, onSortOrderClicked: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - animatedVisibilityScope: RUNTIME (requires runtime check) - - navigator: STABLE (marked @Stable or @Immutable) + - navigation: RUNTIME (requires runtime check) - currentNodeUuid: STABLE (class with no mutable properties) - isSearchResult: STABLE (primitive type) - actions: RUNTIME (requires runtime check) @@ -237,11 +161,11 @@ internal fun com.wire.android.feature.cells.ui.ConversationFilesScreenContent(an - onSortOrderClicked: STABLE (function type) @Composable -public fun com.wire.android.feature.cells.ui.ConversationFilesWithSlideInTransitionScreen(navigator: com.wire.android.navigation.WireNavigator, cellFilesNavArgs: com.wire.android.feature.cells.ui.CellFilesNavArgs, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.ConversationFilesSlideRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, cellFilesNavArgs: com.wire.android.feature.cells.ui.CellFilesNavArgs, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - navigation: RUNTIME (requires runtime check) - cellFilesNavArgs: UNSTABLE (has mutable properties or unstable members) - animatedVisibilityScope: RUNTIME (requires runtime check) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -323,10 +247,11 @@ internal fun com.wire.android.feature.cells.ui.ViewerOnlyIcon(offsetX: androidx. - offsetY: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.feature.cells.ui.addRemoveTagsViewModel(): com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel - skippable: true +internal fun com.wire.android.feature.cells.ui.addRemoveTagsViewModel(navArgs: com.wire.android.feature.cells.ui.tags.AddRemoveTagsNavArgs): com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel + skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) @Composable internal fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerContent(state: com.wire.android.feature.cells.ui.audioplayer.AudioPlaybackState, fileName: kotlin.String?, onTogglePlayPause: kotlin.Function0, onSeek: kotlin.Function1, onStop: kotlin.Function0, onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -342,13 +267,13 @@ internal fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerConten - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerRouteScreen(onNavigateBack: kotlin.Function0, viewModel: com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - modifier: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.cells.ui.audioplayer.EqualizerBars(isPlaying: kotlin.Boolean): kotlin.Unit @@ -358,31 +283,26 @@ private fun com.wire.android.feature.cells.ui.audioplayer.EqualizerBars(isPlayin - isPlaying: STABLE (primitive type) @Composable -public fun com.wire.android.feature.cells.ui.cellAudioPlayerViewModel(): com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel - skippable: true +internal fun com.wire.android.feature.cells.ui.cellAudioPlayerViewModel(context: android.content.Context, navArgs: com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs): com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel + skippable: false restartable: true params: + - context: RUNTIME (requires runtime check) + - navArgs: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.cellImageViewerViewModel(): com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel +internal fun com.wire.android.feature.cells.ui.cellImageViewerViewModel(navArgs: com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs): com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.cellViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.feature.cells.ui.CellViewModel - skippable: false - restartable: true - params: - - viewModelStoreOwner: RUNTIME (requires runtime check) - -@Composable -public fun com.wire.android.feature.cells.ui.cellsViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, key: kotlin.String?): VM of com.wire.android.feature.cells.ui.cellsViewModel +public fun com.wire.android.feature.cells.ui.cellViewModel(navArgs: com.wire.android.feature.cells.ui.CellFilesNavArgs): com.wire.android.feature.cells.ui.CellViewModel skippable: false restartable: true params: - - viewModelStoreOwner: RUNTIME (requires runtime check) - - key: STABLE (class with no mutable properties) + - navArgs: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.feature.cells.ui.common.Breadcrumbs(pathSegments: kotlin.Array, modifier: androidx.compose.ui.Modifier, isRecycleBin: kotlin.Boolean, onBreadcrumbsFolderClick: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit @@ -464,12 +384,12 @@ public fun com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog(sh - onItemSelected: STABLE (function type) @Composable -public fun com.wire.android.feature.cells.ui.create.file.CreateFileScreen(navigator: com.wire.android.navigation.WireNavigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, modifier: androidx.compose.ui.Modifier, createFileViewModel: com.wire.android.feature.cells.ui.create.file.CreateFileViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.create.file.CreateFileRouteScreen(onNavigateBack: kotlin.Function0, onCreated: kotlin.Function0, modifier: androidx.compose.ui.Modifier, createFileViewModel: com.wire.android.feature.cells.ui.create.file.CreateFileViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) + - onCreated: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - createFileViewModel: UNSTABLE (has mutable properties or unstable members) @@ -481,12 +401,12 @@ private fun com.wire.android.feature.cells.ui.create.file.computeNameErrorState( - error: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.create.folder.CreateFolderScreen(navigator: com.wire.android.navigation.WireNavigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, modifier: androidx.compose.ui.Modifier, createFolderViewModel: com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.create.folder.CreateFolderRouteScreen(onNavigateBack: kotlin.Function0, onCreated: kotlin.Function0, modifier: androidx.compose.ui.Modifier, createFolderViewModel: com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) + - onCreated: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - createFolderViewModel: UNSTABLE (has mutable properties or unstable members) @@ -498,16 +418,18 @@ private fun com.wire.android.feature.cells.ui.create.folder.computeNameErrorStat - error: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.createFileViewModel(): com.wire.android.feature.cells.ui.create.file.CreateFileViewModel +internal fun com.wire.android.feature.cells.ui.createFileViewModel(navArgs: com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs): com.wire.android.feature.cells.ui.create.file.CreateFileViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.createFolderViewModel(): com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel +internal fun com.wire.android.feature.cells.ui.createFolderViewModel(navArgs: com.wire.android.feature.cells.ui.create.folder.CreateFolderScreenNavArgs): com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable public fun com.wire.android.feature.cells.ui.dialog.BottomSheetMenuItem(action: com.wire.android.feature.cells.ui.model.NodeBottomSheetAction, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean): kotlin.Unit @@ -600,15 +522,6 @@ private fun com.wire.android.feature.cells.ui.dialog.ShowRecycleBinItem(title: k - onClicked: STABLE (function type) - enabled: STABLE (primitive type) -@Composable -public fun com.wire.android.feature.cells.ui.imageviewer.CellImageViewerScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel): kotlin.Unit - skippable: false - restartable: true - params: - - navigator: STABLE (marked @Stable or @Immutable) - - modifier: STABLE (marked @Stable or @Immutable) - - viewModel: UNSTABLE (has mutable properties or unstable members) - @Composable internal fun com.wire.android.feature.cells.ui.imageviewer.CellImageViewerScreenContent(localPath: kotlin.String?, contentUrl: kotlin.String?, previewUrl: kotlin.String?, contentHash: kotlin.String?, fileName: kotlin.String?, onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -635,10 +548,11 @@ public fun com.wire.android.feature.cells.ui.imageviewer.CellZoomableImage(local - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.feature.cells.ui.moveToFolderViewModel(): com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel - skippable: true +internal fun com.wire.android.feature.cells.ui.moveToFolderViewModel(navArgs: com.wire.android.feature.cells.ui.movetofolder.MoveToFolderNavArgs): com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel + skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) @Composable private fun com.wire.android.feature.cells.ui.movetofolder.BottomScreenBar(viewState: com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewState, onCreateFolderClick: kotlin.Function0, onMoveToFolderClick: kotlin.Function0): kotlin.Unit @@ -657,12 +571,15 @@ private fun com.wire.android.feature.cells.ui.movetofolder.EmptyScreen(isRootFol - isRootFolder: STABLE (primitive type) @Composable -public fun com.wire.android.feature.cells.ui.movetofolder.MoveToFolderScreen(navigator: com.wire.android.navigation.WireNavigator, createFolderResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, modifier: androidx.compose.ui.Modifier, moveToFolderViewModel: com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScreen(onNavigateBack: kotlin.Function0, onCloseFlow: kotlin.Function0, onNavigateBackSteps: kotlin.Function1, onOpenCreateFolder: kotlin.Function1, onOpenFolder: kotlin.Function4, kotlin.Unit>, modifier: androidx.compose.ui.Modifier, moveToFolderViewModel: com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - createFolderResultRecipient: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) + - onCloseFlow: STABLE (function type) + - onNavigateBackSteps: STABLE (function type) + - onOpenCreateFolder: STABLE (function type) + - onOpenFolder: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - moveToFolderViewModel: UNSTABLE (has mutable properties or unstable members) @@ -692,22 +609,25 @@ private fun com.wire.android.feature.cells.ui.movetofolder.TopScreenBar(breadcru - onNavigateBack: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.publicLinkExpirationScreenViewModel(): com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel +internal fun com.wire.android.feature.cells.ui.publicLinkExpirationViewModel(navArgs: com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenNavArgs): com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable -internal fun com.wire.android.feature.cells.ui.publicLinkPasswordScreenViewModel(): com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel +internal fun com.wire.android.feature.cells.ui.publicLinkPasswordViewModel(navArgs: com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordNavArgs): com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.publicLinkViewModel(): com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel +internal fun com.wire.android.feature.cells.ui.publicLinkViewModel(navArgs: com.wire.android.feature.cells.ui.publiclink.PublicLinkNavArgs): com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable private fun com.wire.android.feature.cells.ui.publiclink.EnableLinkSection(checked: kotlin.Boolean, isFolder: kotlin.Boolean, onCheckClick: kotlin.Function0): kotlin.Unit @@ -719,13 +639,13 @@ private fun com.wire.android.feature.cells.ui.publiclink.EnableLinkSection(check - onCheckClick: STABLE (function type) @Composable -public fun com.wire.android.feature.cells.ui.publiclink.PublicLinkScreen(navigator: com.wire.android.navigation.WireNavigator, onPasswordChange: com.ramcosta.composedestinations.result.ResultRecipient, onExpirationChange: com.ramcosta.composedestinations.result.ResultRecipient, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.publiclink.PublicLinkRouteScreen(onNavigateBack: kotlin.Function0, onOpenPasswordSettings: kotlin.Function2, onOpenExpirationSettings: kotlin.Function2, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - onPasswordChange: STABLE (marked @Stable or @Immutable) - - onExpirationChange: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) + - onOpenPasswordSettings: STABLE (function type) + - onOpenExpirationSettings: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -745,13 +665,6 @@ private fun com.wire.android.feature.cells.ui.publiclink.formattedExpirationErro restartable: true params: -@Composable -private fun com.wire.android.feature.cells.ui.publiclink.handleNavResult(block: kotlin.Function1): kotlin.Unit - skippable: true - restartable: true - params: - - block: STABLE (function type) - @Composable internal fun com.wire.android.feature.cells.ui.publiclink.settings.LinkSettingsOption(isEnabled: kotlin.Boolean, title: kotlin.String, subtitle: kotlin.String, errorLabel: kotlin.String?, onClick: kotlin.Function0): kotlin.Unit skippable: true @@ -836,11 +749,11 @@ private fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.Exp - onSetClick: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreen(resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationRouteScreen(onResult: kotlin.Function1, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel): kotlin.Unit skippable: false restartable: true params: - - resultNavigator: STABLE (marked @Stable or @Immutable) + - onResult: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -927,20 +840,20 @@ public fun com.wire.android.feature.cells.ui.publiclink.settings.password.Passwo - onSetPassword: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreen(resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordRouteScreen(onResult: kotlin.Function1, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel): kotlin.Unit skippable: false restartable: true params: - - resultNavigator: STABLE (marked @Stable or @Immutable) + - onResult: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.feature.cells.ui.recyclebin.RecycleBinScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.recyclebin.RecycleBinRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, modifier: androidx.compose.ui.Modifier, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - navigation: RUNTIME (requires runtime check) - modifier: STABLE (marked @Stable or @Immutable) - cellViewModel: UNSTABLE (has mutable properties or unstable members) @@ -974,11 +887,11 @@ public fun com.wire.android.feature.cells.ui.recyclebin.UnableToRestoreDialog(is - onDismiss: STABLE (function type) @Composable -public fun com.wire.android.feature.cells.ui.rename.RenameNodeScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, renameNodeViewModel: com.wire.android.feature.cells.ui.rename.RenameNodeViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.rename.RenameNodeRouteScreen(onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, renameNodeViewModel: com.wire.android.feature.cells.ui.rename.RenameNodeViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - renameNodeViewModel: UNSTABLE (has mutable properties or unstable members) @@ -991,17 +904,18 @@ private fun com.wire.android.feature.cells.ui.rename.computeNameErrorState(error - isFolder: STABLE (primitive type) @Composable -public fun com.wire.android.feature.cells.ui.renameNodeViewModel(): com.wire.android.feature.cells.ui.rename.RenameNodeViewModel +internal fun com.wire.android.feature.cells.ui.renameNodeViewModel(navArgs: com.wire.android.feature.cells.ui.rename.RenameNodeNavArgs): com.wire.android.feature.cells.ui.rename.RenameNodeViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.feature.cells.ui.search.SearchScreen(navigator: com.wire.android.navigation.WireNavigator, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel, modifier: androidx.compose.ui.Modifier, searchScreenViewModel: com.wire.android.feature.cells.ui.search.SearchScreenViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.search.SearchRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel, modifier: androidx.compose.ui.Modifier, searchScreenViewModel: com.wire.android.feature.cells.ui.search.SearchScreenViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - navigation: RUNTIME (requires runtime check) - animatedVisibilityScope: RUNTIME (requires runtime check) - cellViewModel: UNSTABLE (has mutable properties or unstable members) - modifier: STABLE (marked @Stable or @Immutable) @@ -1178,10 +1092,18 @@ public fun com.wire.android.feature.cells.ui.search.sort.SortRowWithMenu(sorting - isSearchResult: STABLE (primitive type) @Composable -public fun com.wire.android.feature.cells.ui.searchScreenViewModel(): com.wire.android.feature.cells.ui.search.SearchScreenViewModel +internal fun com.wire.android.feature.cells.ui.searchCellViewModel(navArgs: com.wire.android.feature.cells.ui.search.SearchNavArgs): com.wire.android.feature.cells.ui.CellViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) + +@Composable +internal fun com.wire.android.feature.cells.ui.searchScreenViewModel(navArgs: com.wire.android.feature.cells.ui.search.SearchNavArgs): com.wire.android.feature.cells.ui.search.SearchScreenViewModel + skippable: true + restartable: true + params: + - navArgs: STABLE (class with no mutable properties) @Composable private fun com.wire.android.feature.cells.ui.subtitle(showConversationName: kotlin.Boolean): kotlin.String? @@ -1191,11 +1113,11 @@ private fun com.wire.android.feature.cells.ui.subtitle(showConversationName: kot - showConversationName: STABLE (primitive type) @Composable -public fun com.wire.android.feature.cells.ui.tags.AddRemoveTagsScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, addRemoveTagsViewModel: com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.tags.AddRemoveTagsRouteScreen(onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, addRemoveTagsViewModel: com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - addRemoveTagsViewModel: UNSTABLE (has mutable properties or unstable members) @@ -1229,10 +1151,11 @@ public fun com.wire.android.feature.cells.ui.tags.ChipAndTextFieldLayout(textFie - chipsLayout: STABLE (composable function type) @Composable -public fun com.wire.android.feature.cells.ui.versionHistoryViewModel(): com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel +internal fun com.wire.android.feature.cells.ui.versionHistoryViewModel(navArgs: com.wire.android.feature.cells.ui.versioning.VersionHistoryNavArgs): com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel skippable: true restartable: true params: + - navArgs: STABLE (class with no mutable properties) @Composable private fun com.wire.android.feature.cells.ui.versioning.DownloadVersionModalItem(title: kotlin.String, onClicked: kotlin.Function0): kotlin.Unit @@ -1261,11 +1184,11 @@ public fun com.wire.android.feature.cells.ui.versioning.VersionActionBottomSheet - onDownloadVersionClicked: STABLE (function type) @Composable -public fun com.wire.android.feature.cells.ui.versioning.VersionHistoryScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, versionHistoryViewModel: com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.versioning.VersionHistoryRouteScreen(onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, versionHistoryViewModel: com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onNavigateBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - versionHistoryViewModel: UNSTABLE (has mutable properties or unstable members) @@ -1333,11 +1256,3 @@ public fun com.wire.android.feature.cells.ui.versioning.restore.RestoreNodeVersi - restoreVersionState: STABLE (class with no mutable properties) - restoreProgress: STABLE (primitive type) -@Composable -public fun com.wire.android.feature.cells.ui.videoplayer.VideoPlayerScreen(navigator: com.wire.android.navigation.WireNavigator, navArgs: com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs): kotlin.Unit - skippable: true - restartable: true - params: - - navigator: STABLE (marked @Stable or @Immutable) - - navArgs: STABLE (class with no mutable properties) - diff --git a/features/meetings/build.gradle.kts b/features/meetings/build.gradle.kts index 7841d0eff8a..ec3e3394e56 100644 --- a/features/meetings/build.gradle.kts +++ b/features/meetings/build.gradle.kts @@ -3,8 +3,6 @@ plugins { id(libs.plugins.wire.kover.get().pluginId) id(BuildPlugins.kotlinParcelize) id(BuildPlugins.junit5) - alias(libs.plugins.ksp) - id(libs.plugins.wire.android.navigation.get().pluginId) id(libs.plugins.wire.compose.compiler.get().pluginId) alias(libs.plugins.compose.stability.analyzer) alias(libs.plugins.kotlin.serialization) @@ -15,6 +13,7 @@ dependencies { implementation("com.wire.kalium:kalium-logic") implementation("com.wire.kalium:kalium-util") implementation(projects.core.di) + implementation(projects.core.navigation) implementation(projects.core.uiCommon) implementation(projects.core.search) implementation(libs.androidx.core) @@ -46,10 +45,3 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) testImplementation(testFixtures(project(":core:ui-common"))) } - -android { - ksp { - arg("compose-destinations.moduleName", "meetings") - arg("compose-destinations.mode", "destinations") - } -} diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/NewMeetingNavGraph.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/NewMeetingNavGraph.kt deleted file mode 100644 index 7ad58a68290..00000000000 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/NewMeetingNavGraph.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.meetings.navigation - -import com.ramcosta.composedestinations.annotation.ExternalModuleGraph -import com.ramcosta.composedestinations.annotation.NavGraph - -@NavGraph -annotation class NewMeetingNavGraph( - val start: Boolean = false -) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt index b699964c565..8ea3b215afa 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt @@ -18,8 +18,7 @@ package com.wire.android.feature.meetings.ui import androidx.lifecycle.ViewModel -import androidx.lifecycle.createSavedStateHandle -import androidx.lifecycle.viewmodel.CreationExtras +import com.wire.android.feature.meetings.ui.create.NewMeetingNavArgs import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelImpl import com.wire.android.feature.meetings.ui.list.MeetingListViewModelImpl import com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModelImpl @@ -28,8 +27,6 @@ import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provides import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey -import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory -import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import dev.zacsweers.metrox.viewmodel.ViewModelKey @BindingContainer @@ -38,24 +35,20 @@ object MeetingsMetroViewModelBindings { @Provides @IntoMap @ManualViewModelAssistedFactoryKey(MeetingsManualViewModelFactory::class) - fun meetingsManualViewModelFactory(factory: MeetingListViewModelImpl.Factory): ManualViewModelAssistedFactory = + fun meetingsManualViewModelFactory( + meetingListFactory: MeetingListViewModelImpl.Factory, + newMeetingFactory: NewMeetingViewModelImpl.Factory, + ): ManualViewModelAssistedFactory = object : MeetingsManualViewModelFactory { override fun meetingListViewModel(type: MeetingsTabItem): MeetingListViewModelImpl = - factory.create(type) + meetingListFactory.create(type) + + override fun newMeetingViewModel(navArgs: NewMeetingNavArgs): NewMeetingViewModelImpl = + newMeetingFactory.create(navArgs) } @Provides @IntoMap @ViewModelKey(MeetingOptionsMenuViewModelImpl::class) - fun meetingOptionsMenuViewModel(viewModel: MeetingOptionsMenuViewModelImpl): ViewModel = - viewModel - - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(NewMeetingViewModelImpl::class) - fun newMeetingViewModel(factory: NewMeetingViewModelImpl.Factory): ViewModelAssistedFactory = - object : ViewModelAssistedFactory { - override fun create(extras: CreationExtras): ViewModel = - factory.create(extras.createSavedStateHandle()) - } + fun meetingOptionsMenuViewModel(viewModel: MeetingOptionsMenuViewModelImpl): ViewModel = viewModel } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt index dbfd39147ca..13e64c42220 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt @@ -23,9 +23,9 @@ import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.wire.android.di.PreviewProvider -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs -import com.wire.android.di.metro.sessionKeyedMetroViewModelAs -import com.wire.android.feature.meetings.ui.create.NewMeetingType +import com.wire.android.di.metro.wireAssistedMetroViewModelAs +import com.wire.android.di.metro.wireMetroViewModelAs +import com.wire.android.feature.meetings.ui.create.NewMeetingNavArgs import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelImpl import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelPreview @@ -37,29 +37,33 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface MeetingsManualViewModelFactory : ManualViewModelAssistedFactory { fun meetingListViewModel(type: MeetingsTabItem): MeetingListViewModelImpl + fun newMeetingViewModel(navArgs: NewMeetingNavArgs): NewMeetingViewModelImpl } @Composable fun meetingListViewModel( type: MeetingsTabItem, ): MeetingListViewModel = - sessionKeyedAssistedMetroViewModelAs( - key = "meeting_list_${type.name}", + wireAssistedMetroViewModelAs( + instanceKey = "meeting_list_${type.name}", ) { meetingListViewModel(type) } @Composable fun meetingOptionsMenuListViewModel(): MeetingOptionsMenuViewModel = - sessionKeyedMetroViewModelAs() + wireMetroViewModelAs() @Composable -fun newMeetingViewModel( +internal fun newMeetingViewModel( + navArgs: NewMeetingNavArgs, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, ): NewMeetingViewModel = - sessionKeyedMetroViewModelAs( - viewModelStoreOwner = viewModelStoreOwner, - previewProvider = PreviewProvider.of(NewMeetingViewModelPreview(NewMeetingType.Schedule)), - ) + wireAssistedMetroViewModelAs( + owner = viewModelStoreOwner, + previewProvider = PreviewProvider.of(NewMeetingViewModelPreview(navArgs.type)), + ) { + newMeetingViewModel(navArgs) + } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3.kt new file mode 100644 index 00000000000..ea58f3e7941 --- /dev/null +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3.kt @@ -0,0 +1,103 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.meetings.ui.create + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import com.wire.kalium.logic.data.id.MeetingId +import kotlinx.serialization.Serializable + +@Serializable +enum class NewMeetingRouteType { + MEET_NOW, + SCHEDULE, + EDIT, +} + +@Serializable +sealed interface NewMeetingRoute : SessionRoute { + override val flowId: String +} + +@Serializable +data class NewMeetingDetailsRoute( + override val sessionId: WireSessionId, + override val flowId: String, + val type: NewMeetingRouteType, + val meetingId: MeetingId? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewMeetingRoute { + override val routeId: String = ROUTE_ID + + init { + require(flowId.isNotBlank()) + } + + companion object { + const val ROUTE_ID = "meetings/new_meeting_screen" + + fun start( + sessionId: WireSessionId, + type: NewMeetingRouteType, + meetingId: MeetingId? = null, + entryId: WireNavEntryId = WireNavEntryId.random(), + ) = NewMeetingDetailsRoute( + sessionId = sessionId, + flowId = "new-meeting:${entryId.value}", + type = type, + meetingId = meetingId, + entryId = entryId, + ) + + fun start( + sessionId: WireSessionId, + type: NewMeetingType, + entryId: WireNavEntryId = WireNavEntryId.random(), + ) = start( + sessionId = sessionId, + type = type.toNavigation3Type(), + meetingId = (type as? NewMeetingType.Edit)?.id, + entryId = entryId, + ) + } +} + +@Serializable +data class NewMeetingParticipantsRoute( + override val sessionId: WireSessionId, + override val flowId: String, + val type: NewMeetingRouteType, + val meetingId: MeetingId? = null, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : NewMeetingRoute { + override val routeId: String = ROUTE_ID + + init { + require(flowId.isNotBlank()) + } + + companion object { + const val ROUTE_ID = "meetings/new_meeting_participants_screen" + } +} + +fun NewMeetingType.toNavigation3Type(): NewMeetingRouteType = when (this) { + NewMeetingType.MeetNow -> NewMeetingRouteType.MEET_NOW + NewMeetingType.Schedule -> NewMeetingRouteType.SCHEDULE + is NewMeetingType.Edit -> NewMeetingRouteType.EDIT +} + +internal fun NewMeetingRouteType.toLegacyType(meetingId: MeetingId? = null): NewMeetingType = when (this) { + NewMeetingRouteType.MEET_NOW -> NewMeetingType.MeetNow + NewMeetingRouteType.SCHEDULE -> NewMeetingType.Schedule + NewMeetingRouteType.EDIT -> NewMeetingType.Edit(requireNotNull(meetingId)) +} diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt new file mode 100644 index 00000000000..d7f58932b58 --- /dev/null +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt @@ -0,0 +1,121 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.meetings.ui.create + +import androidx.compose.runtime.Composable +import com.wire.android.feature.meetings.ui.newMeetingViewModel +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.android.navigation.navigation3.wireViewModelStoreOwner +import com.wire.android.ui.common.HandleActions +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireViewModelOwner + +interface MeetingsNavigation3Actions { + fun exitMeetingFlow() + fun openUserProfile(userId: MeetingParticipantId) +} + +@kotlinx.serialization.Serializable +data class MeetingParticipantId(val value: String, val domain: String) { + init { + require(value.isNotBlank()) + require(domain.isNotBlank()) + } +} + +object MeetingsNavigation3Contribution { + const val ROUTE_REGISTRATION_COUNT: Int = 2 + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + actions: MeetingsNavigation3Actions, + ): List = + listOf(meetingsNavigation3Entries(runtime, actions)) +} + +fun meetingsNavigation3Entries( + runtime: WireNavigation3Runtime, + actions: MeetingsNavigation3Actions, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + NewMeetingDetailsNavigation3Entry(route, runtime, actions) + } + wireEntry(presentation = WireEntryPresentation.PopUp) { + NewMeetingParticipantsNavigation3Entry(it, runtime, actions) + } +} + +@Composable +private fun NewMeetingDetailsNavigation3Entry( + route: NewMeetingDetailsRoute, + runtime: WireNavigation3Runtime, + actions: MeetingsNavigation3Actions, +) { + val viewModel = newMeetingFlowViewModel(route.type, route.meetingId, route.flowId) + NewMeetingContent( + type = route.type.toLegacyType(route.meetingId), + onBackPressed = { if (!runtime.navigator.goBack()) actions.exitMeetingFlow() }, + state = viewModel.state, + titleState = viewModel.titleTextState, + onParticipantsClicked = { + runtime.navigator.navigate( + WireNavigationCommand( + NewMeetingParticipantsRoute(route.sessionId, route.flowId, route.type, route.meetingId) + ) + ) + }, + onCreateClicked = { + when (route.type) { + NewMeetingRouteType.EDIT -> viewModel.submitUpdate() + NewMeetingRouteType.MEET_NOW, + NewMeetingRouteType.SCHEDULE, + -> viewModel.submitCreation() + } + }, + onStartTimeChanged = viewModel::updateStartTime, + onEndTimeChanged = viewModel::updateEndTime, + onRepeatingIntervalChanged = viewModel::updateRepeatingInterval, + ) + HandleActions(viewModel.actions) { + if (!runtime.navigator.goBack()) actions.exitMeetingFlow() + } +} + +@Composable +private fun NewMeetingParticipantsNavigation3Entry( + route: NewMeetingParticipantsRoute, + runtime: WireNavigation3Runtime, + actions: MeetingsNavigation3Actions, +) { + NewMeetingParticipantsRouteContent( + newMeetingViewModel = newMeetingFlowViewModel(route.type, route.meetingId, route.flowId), + onNavigateBack = runtime.navigator::goBack, + onOpenUserProfile = { + actions.openUserProfile(MeetingParticipantId(it.value, it.domain)) + }, + ) +} + +@Composable +private fun newMeetingFlowViewModel( + type: NewMeetingRouteType, + meetingId: com.wire.kalium.logic.data.id.MeetingId?, + flowId: String, +): NewMeetingViewModel { + val flowOwner = wireViewModelStoreOwner(WireViewModelOwner.Flow(flowId)) + return newMeetingViewModel( + navArgs = NewMeetingNavArgs(type.toLegacyType(meetingId)), + viewModelStoreOwner = flowOwner, + ) +} diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt index 6dd88f4ab26..42f0ca69a25 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt @@ -30,10 +30,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import com.wire.android.feature.meetings.R -import com.wire.android.feature.meetings.navigation.MeetingNavigator import com.wire.android.model.ItemActionType -import com.wire.android.navigation.annotation.features.meetings.WireNewMeetingDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.search.SearchUsersAndAppsScreen import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton @@ -44,13 +41,11 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.kalium.logic.data.user.UserId import com.wire.android.ui.common.R as commonR -@WireNewMeetingDestination( - style = PopUpNavigationAnimation::class, -) @Composable -fun NewMeetingParticipantsScreen( - navigator: MeetingNavigator, +internal fun NewMeetingParticipantsRouteContent( newMeetingViewModel: NewMeetingViewModel, + onNavigateBack: () -> Unit, + onOpenUserProfile: (UserId) -> Unit, ) { SearchUsersAndAppsScreen( onlyConnectedContacts = true, @@ -59,19 +54,19 @@ fun NewMeetingParticipantsScreen( onContactChecked = newMeetingViewModel::updateSelectedContact, onClose = { newMeetingViewModel.resetSelectedContacts() - navigator.navigateBack() + onNavigateBack() }, navigationIconType = NavigationIconType.Back(R.string.content_description_new_meeting_participants_back_icon), itemActionType = ItemActionType.CHECK, isAppsTabVisible = false, onOpenUserProfile = { contact -> - navigator.navigateToProfile(UserId(contact.id, contact.domain)) + onOpenUserProfile(UserId(contact.id, contact.domain)) }, peopleBottomActions = { focusRequester -> SelectButton( onClick = { newMeetingViewModel.confirmSelectedContacts() - navigator.navigateBack() + onNavigateBack() }, buttonModifier = Modifier.focusRequester(focusRequester), ) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt index 7f89fcb46ea..2d8c9cbbd9a 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt @@ -17,17 +17,13 @@ */ package com.wire.android.feature.meetings.ui.create -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -55,7 +51,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource @@ -66,21 +61,12 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign -import com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingParticipantsScreenDestination import com.wire.android.feature.meetings.R import com.wire.android.feature.meetings.model.MeetingItem import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel.Companion.MEETING_NAME_MAX_COUNT import com.wire.android.feature.meetings.ui.util.PreviewMultipleThemes import com.wire.android.model.Contact -import com.wire.android.navigation.NavigationCommand -import com.wire.android.navigation.WireNavigator -import com.wire.android.navigation.annotation.features.meetings.WireNewMeetingDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation -import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.VisibilityState -import com.wire.android.ui.common.WireDialog -import com.wire.android.ui.common.WireDialogButtonProperties -import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.WireDropDown import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState @@ -91,7 +77,6 @@ import com.wire.android.ui.common.datetime.WireDatePickerDialog import com.wire.android.ui.common.datetime.WireTimePickerDialog import com.wire.android.ui.common.datetime.asTimePickerResult import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.spacers.VerticalSpace @@ -112,7 +97,6 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CurrentTimeProvider import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.EMPTY -import com.wire.kalium.logic.data.id.MeetingId import com.wire.kalium.logic.data.user.ConnectionState import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList @@ -125,49 +109,6 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.hours import com.wire.android.ui.common.R as commonR -@WireNewMeetingDestination( - start = true, - navArgs = NewMeetingNavArgs::class, - style = PopUpNavigationAnimation::class, -) -@Composable -fun NewMeetingScreen( - navigator: WireNavigator, - navArgs: NewMeetingNavArgs, - newMeetingViewModel: NewMeetingViewModel, -) { - NewMeetingContent( - type = navArgs.type, - onBackPressed = navigator::navigateBack, - state = newMeetingViewModel.state, - titleState = newMeetingViewModel.titleTextState, - onParticipantsClicked = { - navigator.navigate(NavigationCommand(NewMeetingParticipantsScreenDestination)) - }, - onCreateClicked = newMeetingViewModel::submitCreation, - onUpdateClicked = newMeetingViewModel::submitUpdate, - onStartTimeChanged = newMeetingViewModel::updateStartTime, - onEndTimeChanged = newMeetingViewModel::updateEndTime, - onRepeatingIntervalChanged = newMeetingViewModel::updateRepeatingInterval, - ) - - if (newMeetingViewModel.state.submitError != null) { - NewMeetingErrorDialog( - type = newMeetingViewModel.type, - onDismiss = newMeetingViewModel::dismissCreationError - ) - } - if (newMeetingViewModel.state.initialLoading == NewMeetingState.InitialLoadingState.Error) { - FailedToLoadEditMeetingDataError(navigateBack = navigator::navigateBack) - } - - HandleActions(newMeetingViewModel.actions) { action -> - when (action) { - is NewMeetingViewActions.Success -> navigator.navigateBack() - } - } -} - @Composable fun NewMeetingContent( state: NewMeetingState, @@ -177,7 +118,6 @@ fun NewMeetingContent( onBackPressed: () -> Unit = {}, onParticipantsClicked: () -> Unit = {}, onCreateClicked: () -> Unit = {}, - onUpdateClicked: () -> Unit = {}, onStartTimeChanged: (startTime: Instant) -> Unit = {}, onEndTimeChanged: (endTime: Instant) -> Unit = {}, onRepeatingIntervalChanged: (interval: MeetingItem.RepeatingInterval?) -> Unit = {}, @@ -189,9 +129,7 @@ fun NewMeetingContent( WireCenterAlignedTopAppBar( elevation = scrollState.rememberTopBarElevationState().value, title = stringResource(type.title), - onNavigationPressed = { - if (!state.isSubmitting) onBackPressed() - }, + onNavigationPressed = onBackPressed, navigationIconType = NavigationIconType.Back( contentDescription = R.string.content_description_new_meeting_back_icon ), @@ -211,9 +149,8 @@ fun NewMeetingContent( TitleInput( titleState = titleState, titleError = state.titleError, - readOnly = state.isSubmitting, ) - if (type != NewMeetingType.MeetNow) { + if (type == NewMeetingType.Schedule) { VerticalSpace.x24() TimeInput( time = state.startTime, @@ -244,29 +181,6 @@ fun NewMeetingContent( onClick = onParticipantsClicked, ) } - if (state.isSubmitting || state.initialLoading != NewMeetingState.InitialLoadingState.Loaded) { - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures { /* consume the touch while submitting or loading data */ } - }, - ) - } - AnimatedVisibility(state.initialLoading != NewMeetingState.InitialLoadingState.Loaded) { - Box( - modifier = Modifier - .fillMaxSize() - .background(colorsScheme().background), - contentAlignment = Alignment.Center - ) { - WireCircularProgressIndicator( - size = dimensions().spacing32x, - strokeWidth = dimensions().spacing4x, - progressColor = MaterialTheme.wireColorScheme.onBackground, - ) - } - } }, bottomBar = { Surface( @@ -284,11 +198,7 @@ fun NewMeetingContent( ) }, state = if (state.continueButtonEnabled) WireButtonState.Default else WireButtonState.Disabled, - loading = state.isSubmitting, - onClick = when (type) { - is NewMeetingType.Edit -> onUpdateClicked - NewMeetingType.MeetNow, NewMeetingType.Schedule -> onCreateClicked - }, + onClick = onCreateClicked, modifier = Modifier .fillMaxWidth() .padding(dimensions().spacing16x), @@ -296,14 +206,12 @@ fun NewMeetingContent( } } ) - BackHandler(state.isSubmitting) { /* block until submitted */ } } @Composable private fun TitleInput( titleState: TextFieldState, titleError: NewMeetingState.TitleError?, - readOnly: Boolean, ) { val keyboardController = LocalSoftwareKeyboardController.current @@ -313,10 +221,8 @@ private fun TitleInput( state = when (titleError) { is NewMeetingState.TitleError.TitleEmptyError -> WireTextFieldState.Error(stringResource(R.string.new_meeting_title_name_error_empty)) - is NewMeetingState.TitleError.TitleExceedsLimitError -> WireTextFieldState.Error(stringResource(R.string.new_meeting_title_name_error_exceeded_limit)) - else -> WireTextFieldState.Default }, placeholderText = stringResource(R.string.new_meeting_title_input_placeholder), @@ -326,7 +232,6 @@ private fun TitleInput( onKeyboardAction = { keyboardController?.hide() }, testTag = "titleInput", inputTransformation = InputTransformation.maxLengthWithCallback(MEETING_NAME_MAX_COUNT, animate), - readOnly = readOnly, trailingIcon = { Box( modifier = Modifier @@ -591,21 +496,6 @@ private fun RepeatingIntervalDropDown( } } -@Composable -private fun FailedToLoadEditMeetingDataError(navigateBack: () -> Unit) { - WireDialog( - title = stringResource(R.string.new_meeting_edit_init_failure_title), - text = stringResource(R.string.new_meeting_edit_init_failure_description), - onDismiss = navigateBack, - buttonsHorizontalAlignment = false, - optionButton1Properties = WireDialogButtonProperties( - onClick = navigateBack, - text = stringResource(commonR.string.label_ok), - type = WireDialogButtonType.Primary, - ), - ) -} - @PreviewMultipleThemes @Composable fun PreviewNewMeetingScreen_MeetNow() = WireTheme { @@ -615,7 +505,6 @@ fun PreviewNewMeetingScreen_MeetNow() = WireTheme { state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( confirmedContacts = buildContacts(names.size), continueButtonEnabled = true, - initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), ) } @@ -630,22 +519,6 @@ fun PreviewNewMeetingScreen_Schedule() = WireTheme { startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), - initialLoading = NewMeetingState.InitialLoadingState.Loaded, - ), - ) -} - -@PreviewMultipleThemes -@Composable -fun PreviewNewMeetingScreen_Edit() = WireTheme { - NewMeetingContent( - titleState = rememberTextFieldState(), - type = NewMeetingType.Edit(MeetingId("meetingId", "domain")), - state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( - startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), - endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), - repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), - initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), ) } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt index b2cb1f7a62b..d9abd2b7ed8 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt @@ -23,9 +23,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.meetings.navArgs import com.wire.android.feature.meetings.mapper.toRepeatingInterval import com.wire.android.feature.meetings.model.MeetingItem import com.wire.android.feature.meetings.ui.create.NewMeetingState.Companion.initialState @@ -94,7 +92,7 @@ class NewMeetingViewModelPreview( } class NewMeetingViewModelImpl @AssistedInject constructor( - @Assisted savedStateHandle: SavedStateHandle, + @Assisted val navArgs: NewMeetingNavArgs, override val currentTimeProvider: CurrentTimeProvider, private val createNewMeeting: CreateNewMeetingUseCase, private val updateMeeting: UpdateMeetingUseCase, @@ -104,9 +102,8 @@ class NewMeetingViewModelImpl @AssistedInject constructor( ) : ActionsViewModel(), NewMeetingViewModel { @AssistedFactory interface Factory { - fun create(savedStateHandle: SavedStateHandle): NewMeetingViewModelImpl + fun create(navArgs: NewMeetingNavArgs): NewMeetingViewModelImpl } - val navArgs: NewMeetingNavArgs = savedStateHandle.navArgs() override val type: NewMeetingType = navArgs.type override val titleTextState: TextFieldState = TextFieldState() override var state: NewMeetingState by mutableStateOf(initialState(currentTimeProvider)) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingListViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingListViewModel.kt index 183e005292f..676ed4631f8 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingListViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingListViewModel.kt @@ -76,10 +76,12 @@ class MeetingListViewModelImpl @AssistedInject constructor( observeActiveCalls: ObserveActiveCallsUseCase, dispatcher: DispatcherProvider, ) : ViewModel(), MeetingListViewModel { + @AssistedFactory interface Factory { fun create(type: MeetingsTabItem): MeetingListViewModelImpl } + private val alignedTickerFlow = flow { while (currentCoroutineContext().isActive) { val currentTime = currentTimeProvider() diff --git a/features/meetings/src/main/java/com/wire/android/navigation/annotation/features/meetings/WireNewMeetingDestination.kt b/features/meetings/src/main/java/com/wire/android/navigation/annotation/features/meetings/WireNewMeetingDestination.kt deleted file mode 100644 index dbf94556b39..00000000000 --- a/features/meetings/src/main/java/com/wire/android/navigation/annotation/features/meetings/WireNewMeetingDestination.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.annotation.features.meetings - -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.Destination.Companion.COMPOSABLE_NAME -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.wire.android.feature.meetings.navigation.NewMeetingNavGraph -import com.wire.android.navigation.wrapper.TabletDialogWrapper -import com.wire.android.navigation.wrapper.WaitUntilTransitionEndsWrapper -import kotlin.reflect.KClass - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireNewMeetingDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) diff --git a/features/meetings/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt b/features/meetings/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt deleted file mode 100644 index 71a4c5c9623..00000000000 --- a/features/meetings/src/test/kotlin/com/wire/android/config/NavigationTestExtension.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.config - -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext - -/** - * This extension provides a way to mock navigation arguments getters. - * It can be used to mock getting navigation arguments from savedStateHandle, like: savedStateHandle.navArgs() - * or to get arguments from the specific Destination object, like: ExampleScreenDestination.argsFrom(savedStateHandle). - * - * Add this JUnit 5 extension to your test class using - * @JvmField - * @RegisterExtension - * val navigationTestExtension = NavigationTestExtension() - * - * or: - * - * Annotating the class with - * @ExtendWith(NavigationTestExtension::class) - */ -class NavigationTestExtension : BeforeEachCallback, AfterEachCallback { - override fun beforeEach(context: ExtensionContext?) { - mockkStatic("com.ramcosta.composedestinations.generated.meetings.NavArgsGettersKt") - } - - override fun afterEach(context: ExtensionContext?) { - unmockkStatic("com.ramcosta.composedestinations.generated.meetings.NavArgsGettersKt") - } -} diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt new file mode 100644 index 00000000000..d95b671485f --- /dev/null +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt @@ -0,0 +1,55 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.meetings.ui.create + +import java.io.File +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class MeetingsNavigation3SourceTest { + + @Test + fun `typed entries do not depend on generated destinations or Nav2`() { + val source = source("MeetingsNavigation3Entries.kt") + + assertFalse(source.contains("com.ramcosta.composedestinations")) + assertFalse(source.contains("WireNavigator")) + assertFalse(source.contains("SavedStateHandle")) + assertTrue(source.contains("wireEntry")) + assertTrue(source.contains("wireEntry")) + assertTrue(source.contains("wireViewModelStoreOwner(WireViewModelOwner.Flow(flowId))")) + assertTrue(source.contains("viewModelStoreOwner = flowOwner")) + assertTrue(source.contains("newMeetingFlowViewModel(route.type, route.meetingId, route.flowId)")) + } + + @Test + fun `typed meeting view model path passes arguments through Metro assisted factory`() { + val graph = File( + "src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt" + ).readText() + val bindings = File( + "src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt" + ).readText() + val viewModel = File( + "src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt" + ).readText() + + assertTrue(graph.contains("newMeetingViewModel(navArgs: NewMeetingNavArgs)")) + assertTrue(bindings.contains("newMeetingFactory.create(navArgs)")) + assertTrue(viewModel.contains("@Assisted val navArgs: NewMeetingNavArgs")) + assertTrue(viewModel.contains("interface Factory")) + assertFalse(bindings.contains("MeetingsViewModelFactory")) + } + + private fun source(name: String) = + File("src/main/java/com/wire/android/feature/meetings/ui/create/$name").readText() +} diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Test.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Test.kt new file mode 100644 index 00000000000..8479c5635b1 --- /dev/null +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Test.kt @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.meetings.ui.create + +import com.wire.kalium.logic.data.id.MeetingId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +internal class MeetingsNavigation3Test { + + @Test + fun `given legacy meeting types, when mapping to typed route values, then semantics are preserved`() { + assertEquals(NewMeetingRouteType.MEET_NOW, NewMeetingType.MeetNow.toNavigation3Type()) + assertEquals(NewMeetingRouteType.SCHEDULE, NewMeetingType.Schedule.toNavigation3Type()) + assertEquals(NewMeetingRouteType.EDIT, NewMeetingType.Edit(MeetingId("meeting", "wire.test")).toNavigation3Type()) + } + + @Test + fun `given edit meeting flow, when route is restored, then meeting identity survives`() { + val meetingId = MeetingId("meeting", "wire.test") + val route = NewMeetingDetailsRoute.start(Session, NewMeetingType.Edit(meetingId)) + + val restored = Json.decodeFromString(Json.encodeToString(route)) + + assertEquals(NewMeetingRouteType.EDIT, restored.type) + assertEquals(meetingId, restored.meetingId) + assertEquals(NewMeetingType.Edit(meetingId), restored.type.toLegacyType(restored.meetingId)) + } + + @Test + fun `given new meeting flow, when opening participants, then both entries share flow and session`() { + val details = NewMeetingDetailsRoute.start(Session, NewMeetingRouteType.SCHEDULE) + val participants = NewMeetingParticipantsRoute(Session, details.flowId, details.type) + + assertEquals(details.sessionId, participants.sessionId) + assertEquals(details.flowId, participants.flowId) + assertEquals(details.type, participants.type) + assertNotEquals(details.entryId, participants.entryId) + } + + @Test + fun `given restored participants route, when serialized, then meeting type and flow ownership survive`() { + val route = NewMeetingParticipantsRoute( + Session, + flowId = "new-meeting:test", + type = NewMeetingRouteType.MEET_NOW, + ) + + val restored = Json.decodeFromString( + Json.encodeToString(route) + ) + + assertEquals(route, restored) + } + + private companion object { + val Session = WireSessionId("user", "wire.test") + } +} diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt index 959e3bebd01..d8ced9c0459 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt @@ -18,41 +18,16 @@ package com.wire.android.feature.meetings.ui.create import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import com.ramcosta.composedestinations.generated.meetings.navArgs import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension -import com.wire.android.feature.meetings.mapper.toRepeatingInterval import com.wire.android.feature.meetings.model.MeetingItem -import com.wire.android.framework.TestUser -import com.wire.android.mapper.ContactMapper import com.wire.android.model.Contact import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.CurrentTimeProvider -import com.wire.kalium.logic.data.conversation.Conversation -import com.wire.kalium.logic.data.conversation.MemberDetails -import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.id.MeetingId -import com.wire.kalium.logic.data.meeting.Meeting -import com.wire.kalium.logic.data.meeting.MeetingOccurrence -import com.wire.kalium.logic.data.meeting.UpsertMeeting import com.wire.kalium.logic.data.user.ConnectionState -import com.wire.kalium.logic.data.user.OtherUser -import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase -import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase -import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase -import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.impl.annotations.MockK import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope @@ -62,17 +37,17 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.datetime.Instant -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import kotlin.time.Duration.Companion.hours @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class, SnapshotExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class NewMeetingViewModelTest { private val dispatcher = StandardTestDispatcher() @@ -231,299 +206,34 @@ class NewMeetingViewModelTest { } @Test - fun givenMeetNowTypeWithValidData_whenSubmitCreationIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { + fun givenValidData_whenCreateMeetingIsCalled_thenSuccessActionIsSent() = runTest(dispatcher) { val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withCurrentTimeProvider { currentTime } - .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Success) - ) - - enterTitle(viewModel, " Quick sync ") - - viewModel.actions.test { - viewModel.submitCreation() - advanceUntilIdle() - - coVerify(exactly = 1) { - arrangement.createNewMeeting( - UpsertMeeting( - title = "Quick sync", - startTime = currentTime, - endTime = currentTime + 1.hours, - recurrence = null, - otherParticipants = emptyList() - ) - ) - } - assertEquals(currentTime, viewModel.state.startTime) - assertEquals(currentTime + 1.hours, viewModel.state.endTime) - assertFalse(viewModel.state.isSubmitting) - assertNull(viewModel.state.submitError) - assertEquals(NewMeetingViewActions.Success, awaitItem()) - cancelAndConsumeRemainingEvents() - } - } - - @Test - fun givenScheduleTypeWithValidData_whenSubmitCreationIsCalled_thenMeetingIsCreatedAndSuccessActionIsSent() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val createMeeting = UPSERT_MEETING.copy(startTime = currentTime + 2.hours, endTime = currentTime + 3.hours) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(NewMeetingType.Schedule) - .withCurrentTimeProvider { currentTime } - .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Success) - ) - enterTitle(viewModel, createMeeting.title) - viewModel.updateStartTime(createMeeting.startTime) - viewModel.updateEndTime(createMeeting.endTime) - viewModel.updateSelectedContact(selected = true, contact = CONTACT) - viewModel.confirmSelectedContacts() - viewModel.updateRepeatingInterval(createMeeting.recurrence?.toRepeatingInterval()) - - viewModel.actions.test { - viewModel.submitCreation() - advanceUntilIdle() - coVerify(exactly = 1) { arrangement.createNewMeeting(createMeeting) } - assertFalse(viewModel.state.isSubmitting) - assertNull(viewModel.state.submitError) - assertEquals(NewMeetingViewActions.Success, awaitItem()) - cancelAndConsumeRemainingEvents() - } - } - - @Test - fun givenCreationFails_whenSubmitCreationIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(NewMeetingType.MeetNow) - .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Failure) - ) + val (_, viewModel) = arrangeViewModel(Arrangement(dispatcher).withCurrentTimeProvider { currentTime }) enterTitle(viewModel, "Weekly sync") viewModel.actions.test { - viewModel.submitCreation() + viewModel.createMeeting() advanceUntilIdle() - coVerify(exactly = 1) { arrangement.createNewMeeting(any()) } - expectNoEvents() - assertFalse(viewModel.state.isSubmitting) - assertEquals(true, viewModel.state.continueButtonEnabled) - assertEquals(NewMeetingState.SubmitError.Other, viewModel.state.submitError) - } - } - - @Test - fun givenEditTypeWithValidData_whenSubmitUpdateIsCalled_thenMeetingIsEditedAndSuccessActionIsSent() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val contact = contact("contact-1") - val createMeeting = UPSERT_MEETING.copy(startTime = currentTime + 2.hours, endTime = currentTime + 3.hours) - val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) - val nextOccurrence = MEETING_OCCURRENCE.copy( - meeting = MEETING_OCCURRENCE.meeting.copy( - startTime = currentTime + 1.hours, - endTime = currentTime + 2.hours, - recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), - ), - occurrenceStartTime = currentTime + 1.hours, - occurrenceEndTime = currentTime + 2.hours, - ) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(editType) - .withNextMeetingOccurrence(nextOccurrence) - .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Success) - .withCurrentTimeProvider { currentTime } - ) - - enterTitle(viewModel, createMeeting.title) - viewModel.updateStartTime(createMeeting.startTime) - viewModel.updateEndTime(createMeeting.endTime) - viewModel.updateSelectedContact(selected = true, contact = contact) - viewModel.confirmSelectedContacts() - viewModel.updateRepeatingInterval(createMeeting.recurrence?.toRepeatingInterval()) - - viewModel.actions.test { - viewModel.submitUpdate() - advanceUntilIdle() - coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, createMeeting) } - assertFalse(viewModel.state.isSubmitting) - assertNull(viewModel.state.submitError) assertEquals(NewMeetingViewActions.Success, awaitItem()) - cancelAndConsumeRemainingEvents() - } - } - - @Test - fun givenEditionFails_whenSubmitUpdateIsCalled_thenErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) - val nextOccurrence = MEETING_OCCURRENCE.copy( - meeting = MEETING_OCCURRENCE.meeting.copy( - startTime = currentTime + 1.hours, - endTime = currentTime + 2.hours, - recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), - ), - occurrenceStartTime = currentTime + 1.hours, - occurrenceEndTime = currentTime + 2.hours, - ) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(editType) - .withNextMeetingOccurrence(nextOccurrence) - .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Failure) - ) - - enterTitle(viewModel, "Weekly sync") - - viewModel.actions.test { - viewModel.submitUpdate() - advanceUntilIdle() - - coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, any()) } - expectNoEvents() - assertFalse(viewModel.state.isSubmitting) - assertEquals(true, viewModel.state.continueButtonEnabled) - assertEquals(NewMeetingState.SubmitError.Other, viewModel.state.submitError) } } @Test - fun givenInvalidTitle_whenSubmitCreationIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { - val (arrangement, viewModel) = arrangeViewModel() + fun givenInvalidTitle_whenCreateMeetingIsCalled_thenTitleErrorIsShownAndSuccessActionIsNotSent() = runTest(dispatcher) { + val (_, viewModel) = arrangeViewModel() viewModel.actions.test { - viewModel.submitCreation() + viewModel.createMeeting() advanceUntilIdle() - coVerify(exactly = 0) { arrangement.createNewMeeting(any()) } - coVerify(exactly = 0) { arrangement.updateMeeting(any(), any()) } expectNoEvents() assertEquals(NewMeetingState.TitleError.TitleEmptyError, viewModel.state.titleError) assertFalse(viewModel.state.continueButtonEnabled) } } - @Test - fun givenScheduleTypeWithInvalidTimes_whenCreateMeetingIsCalled_thenTimeErrorIsShownAndMeetingIsNotCreated() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(NewMeetingType.Schedule) - .withCurrentTimeProvider { currentTime } - ) - - enterTitle(viewModel, "Weekly sync") - viewModel.updateStartTime(currentTime - 1.hours) - - viewModel.actions.test { - viewModel.submitCreation() - advanceUntilIdle() - - coVerify(exactly = 0) { arrangement.createNewMeeting(any()) } - coVerify(exactly = 0) { arrangement.updateMeeting(any(), any()) } - expectNoEvents() - assertEquals(NewMeetingState.TimeError.StartTimeInPastError, viewModel.state.startTimeError) - assertFalse(viewModel.state.continueButtonEnabled) - } - } - - @Test - fun givenEditTypeAndNextOccurrenceExists_whenViewModelIsCreated_thenStateIsInitializedFromNextOccurrence() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val nextOccurrence = MEETING_OCCURRENCE.copy( - meeting = MEETING_OCCURRENCE.meeting.copy( - startTime = currentTime + 1.hours, - endTime = currentTime + 2.hours, - recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), - ), - occurrenceStartTime = currentTime + 1.hours, - occurrenceEndTime = currentTime + 2.hours, - ) - val editType = NewMeetingType.Edit(nextOccurrence.meeting.meetingId) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(editType) - .withCurrentTimeProvider { currentTime } - .withNextMeetingOccurrence(nextOccurrence) - ) - - coVerify(exactly = 1) { arrangement.getNextMeetingOccurrence(editType.id, currentTime) } - assertEquals(editType, viewModel.type) - assertEquals(nextOccurrence.meeting.title, viewModel.titleTextState.text.toString()) - assertEquals(nextOccurrence.occurrenceStartTime, viewModel.state.startTime) - assertEquals(nextOccurrence.occurrenceEndTime, viewModel.state.endTime) - assertEquals(nextOccurrence.meeting.recurrence?.frequency, viewModel.state.repeatingInterval?.frequency) - assertEquals(nextOccurrence.meeting.recurrence?.interval?.toInt(), viewModel.state.repeatingInterval?.interval) - assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) - } - - @Suppress("UnusedFlow") - @Test - fun givenEditTypeAndNextOccurrenceExists_whenViewModelIsCreated_thenParticipantsAreInitializedFromOccurrenceConversation() = - runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val editType = NewMeetingType.Edit(MEETING_OCCURRENCE.meeting.meetingId) - val firstUser = TestUser.OTHER_USER.copy(id = UserId("contact-1", "domain")) - val secondUser = TestUser.OTHER_USER.copy(id = UserId("contact-2", "domain")) - val firstContact = contact("contact-1") - val secondContact = contact("contact-2") - val conversationMembers = listOf( - MemberDetails(firstUser, Conversation.Member.Role.Member), - MemberDetails(secondUser, Conversation.Member.Role.Admin), - ) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(editType) - .withCurrentTimeProvider { currentTime } - .withNextMeetingOccurrence(MEETING_OCCURRENCE) - .withConversationMembers(MEETING_OCCURRENCE.meeting.conversationId, conversationMembers) - .withMappedContact(firstUser, firstContact) - .withMappedContact(secondUser, secondContact) - ) - - coVerify(exactly = 1) { arrangement.observeConversationMembers(MEETING_OCCURRENCE.meeting.conversationId) } - assertEquals(setOf(firstContact, secondContact), viewModel.state.selectedContacts.toSet()) - assertEquals(setOf(firstContact, secondContact), viewModel.state.confirmedContacts.toSet()) - assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) - } - - @Test - fun givenEditTypeAndNextOccurrenceDoesNotExist_whenViewModelIsCreated_thenStateStopsLoadingWithInitialTimes() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(editType) - .withCurrentTimeProvider { currentTime } - .withNextMeetingOccurrence(null) - ) - - coVerify(exactly = 1) { arrangement.getNextMeetingOccurrence(editType.id, currentTime) } - assertEquals(editType, viewModel.type) - assertEquals("", viewModel.titleTextState.text.toString()) - assertEquals(currentTime + 1.hours, viewModel.state.startTime) - assertEquals(currentTime + 2.hours, viewModel.state.endTime) - assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) - } - - @Test - fun givenEditTypeAndNextOccurrenceLoadFails_whenViewModelIsCreated_thenInitialLoadingStateIsError() = runTest(dispatcher) { - val currentTime = Instant.parse("2026-01-01T12:00:00Z") - val editType = NewMeetingType.Edit(MeetingId("meeting-id", "domain")) - val (arrangement, viewModel) = arrangeViewModel( - Arrangement(dispatcher) - .withNewMeetingType(editType) - .withCurrentTimeProvider { currentTime } - .withNextMeetingOccurrenceFailure() - ) - - coVerify(exactly = 1) { arrangement.getNextMeetingOccurrence(editType.id, currentTime) } - assertEquals(NewMeetingState.InitialLoadingState.Error, viewModel.state.initialLoading) - } - private fun TestScope.arrangeViewModel( arrangement: Arrangement = Arrangement(dispatcher) ): Pair = @@ -548,105 +258,18 @@ class NewMeetingViewModelTest { Instant.fromEpochMilliseconds(dispatcher.scheduler.currentTime) } - @MockK - private lateinit var savedStateHandle: SavedStateHandle - - @MockK - lateinit var createNewMeeting: CreateNewMeetingUseCase - - @MockK - lateinit var updateMeeting: UpdateMeetingUseCase - - @MockK - lateinit var getNextMeetingOccurrence: GetNextMeetingOccurrenceUseCase - - @MockK - lateinit var observeConversationMembers: ObserveConversationMembersUseCase - - @MockK - lateinit var contactMapper: ContactMapper - private var newMeetingType: NewMeetingType = NewMeetingType.MeetNow - init { - MockKAnnotations.init(this) - every { - savedStateHandle.navArgs() - } answers { NewMeetingNavArgs(type = newMeetingType) } - coEvery { getNextMeetingOccurrence(any(), any()) } returns null - coEvery { observeConversationMembers(any()) } returns flowOf(emptyList()) - } - fun withNewMeetingType(type: NewMeetingType) = apply { newMeetingType = type } - fun withCurrentTimeProvider(currentTime: () -> Instant) = apply { currentTimeProvider = CurrentTimeProvider(currentTime) } - fun withCreateMeetingResult(result: CreateNewMeetingUseCase.Result) = apply { - coEvery { createNewMeeting(any()) } returns result - } - - fun withUpdateMeetingResult(meetingId: MeetingId, result: UpdateMeetingUseCase.Result) = apply { - coEvery { updateMeeting(meetingId, any()) } returns result - } - - fun withNextMeetingOccurrence(nextMeetingOccurrence: MeetingOccurrence?) = apply { - coEvery { getNextMeetingOccurrence(any(), any()) } returns nextMeetingOccurrence - } - - fun withNextMeetingOccurrenceFailure() = apply { - coEvery { getNextMeetingOccurrence(any(), any()) } throws IllegalStateException("Failed to load meeting occurrence") - } - - fun withConversationMembers(conversationId: ConversationId, members: List) = apply { - coEvery { observeConversationMembers(conversationId) } returns flowOf(members) - } - - fun withMappedContact(otherUser: OtherUser, contact: Contact) = apply { - every { contactMapper.fromOtherUser(otherUser) } returns contact - } - fun arrange() = this to NewMeetingViewModelImpl( - savedStateHandle = savedStateHandle, + navArgs = NewMeetingNavArgs(type = newMeetingType), currentTimeProvider = currentTimeProvider, - createNewMeeting = createNewMeeting, - updateMeeting = updateMeeting, - getNextMeetingOccurrence = getNextMeetingOccurrence, - observeConversationMembers = observeConversationMembers, - contactMapper = contactMapper, ) } - - private val MEETING_OCCURRENCE = MeetingOccurrence( - meeting = Meeting( - meetingId = MeetingId("meeting-id", "domain"), - conversationId = ConversationId("conversation-id", "domain"), - creatorId = UserId("creator-id", "domain"), - title = "Daily", - startTime = Instant.parse("2026-01-01T09:00:00Z"), - endTime = Instant.parse("2026-01-01T10:00:00Z"), - recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), - ), - selfRole = MeetingOccurrence.SelfRole.Creator, - conversationName = "Daily", - conversationType = MeetingOccurrence.ConversationType.Group, - occurrenceId = "occurrence-id", - occurrenceStartTime = Instant.parse("2026-01-02T09:00:00Z"), - occurrenceEndTime = Instant.parse("2026-01-02T10:00:00Z"), - ) - private val CONTACT = contact("contact-1") - private val UPSERT_MEETING = UpsertMeeting( - title = "Weekly sync", - startTime = Instant.parse("2026-01-01T09:00:00Z"), - endTime = Instant.parse("2026-01-01T10:00:00Z"), - recurrence = Meeting.Recurrence( - frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, - interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), - until = null - ), - otherParticipants = listOf(UserId(CONTACT.id, CONTACT.domain)) - ) } diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index a7df71f3941..19b579c55ac 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -4,18 +4,6 @@ // Do not edit this file directly. To update it, run: // ./gradlew :meetings:stabilityDump -@Composable -public fun com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingParticipantsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable public fun com.wire.android.feature.meetings.ui.AllMeetingsScreen(lazyListState: androidx.compose.foundation.lazy.LazyListState, contentPadding: androidx.compose.foundation.layout.PaddingValues, tabs: kotlinx.collections.immutable.ImmutableList, initialTab: com.wire.android.feature.meetings.ui.MeetingsTabItem, startCall: kotlin.Function1<@[ParameterName(name = \, joinCall: kotlin.Function1<@[ParameterName(name = \, returnToCall: kotlin.Function1<@[ParameterName(name = \, editMeeting: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: true @@ -63,6 +51,15 @@ public fun com.wire.android.feature.meetings.ui.create.NewMeetingContent(state: - onEndTimeChanged: STABLE (function type) - onRepeatingIntervalChanged: STABLE (function type) +@Composable +private fun com.wire.android.feature.meetings.ui.create.NewMeetingDetailsNavigation3Entry(route: com.wire.android.feature.meetings.ui.create.NewMeetingDetailsRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.feature.meetings.ui.create.MeetingsNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + @Composable public fun com.wire.android.feature.meetings.ui.create.NewMeetingErrorDialog(type: com.wire.android.feature.meetings.ui.create.NewMeetingType, onDismiss: kotlin.Function0): kotlin.Unit skippable: true @@ -72,21 +69,22 @@ public fun com.wire.android.feature.meetings.ui.create.NewMeetingErrorDialog(typ - onDismiss: STABLE (function type) @Composable -public fun com.wire.android.feature.meetings.ui.create.NewMeetingParticipantsScreen(navigator: com.wire.android.feature.meetings.navigation.MeetingNavigator, newMeetingViewModel: com.wire.android.feature.meetings.ui.create.NewMeetingViewModel): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.create.NewMeetingParticipantsNavigation3Entry(route: com.wire.android.feature.meetings.ui.create.NewMeetingParticipantsRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.feature.meetings.ui.create.MeetingsNavigation3Actions): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (class with no mutable properties) - - newMeetingViewModel: RUNTIME (requires runtime check) + - route: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.feature.meetings.ui.create.NewMeetingScreen(navigator: com.wire.android.navigation.WireNavigator, navArgs: com.wire.android.feature.meetings.ui.create.NewMeetingNavArgs, newMeetingViewModel: com.wire.android.feature.meetings.ui.create.NewMeetingViewModel): kotlin.Unit +internal fun com.wire.android.feature.meetings.ui.create.NewMeetingParticipantsRouteContent(newMeetingViewModel: com.wire.android.feature.meetings.ui.create.NewMeetingViewModel, onNavigateBack: kotlin.Function0, onOpenUserProfile: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - navArgs: STABLE (class with no mutable properties) - newMeetingViewModel: RUNTIME (requires runtime check) + - onNavigateBack: STABLE (function type) + - onOpenUserProfile: STABLE (function type) @Composable private fun com.wire.android.feature.meetings.ui.create.ParticipantsInput(participants: kotlinx.collections.immutable.ImmutableSet, onClick: kotlin.Function0): kotlin.Unit @@ -128,13 +126,21 @@ private fun com.wire.android.feature.meetings.ui.create.TimeInput(time: kotlinx. - timePlaceholder: STABLE (String is immutable) @Composable -private fun com.wire.android.feature.meetings.ui.create.TitleInput(titleState: androidx.compose.foundation.text.input.TextFieldState, titleError: com.wire.android.feature.meetings.ui.create.NewMeetingState.TitleError?, readOnly: kotlin.Boolean): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.create.TitleInput(titleState: androidx.compose.foundation.text.input.TextFieldState, titleError: com.wire.android.feature.meetings.ui.create.NewMeetingState.TitleError?): kotlin.Unit skippable: true restartable: true params: - titleState: STABLE (marked @Stable or @Immutable) - titleError: STABLE (marked @Stable or @Immutable) - - readOnly: STABLE (primitive type) + +@Composable +private fun com.wire.android.feature.meetings.ui.create.newMeetingFlowViewModel(type: com.wire.android.feature.meetings.ui.create.NewMeetingRouteType, meetingId: com.wire.kalium.logic.data.id.QualifiedID?, flowId: kotlin.String): com.wire.android.feature.meetings.ui.create.NewMeetingViewModel + skippable: false + restartable: true + params: + - type: STABLE (class with no mutable properties) + - meetingId: UNSTABLE (has mutable properties or unstable members) + - flowId: STABLE (String is immutable) @Composable internal fun com.wire.android.feature.meetings.ui.list.CalendarIcon(tint: androidx.compose.ui.graphics.Color, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -319,10 +325,11 @@ public fun com.wire.android.feature.meetings.ui.meetingOptionsMenuListViewModel( params: @Composable -public fun com.wire.android.feature.meetings.ui.newMeetingViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.feature.meetings.ui.create.NewMeetingViewModel +internal fun com.wire.android.feature.meetings.ui.newMeetingViewModel(navArgs: com.wire.android.feature.meetings.ui.create.NewMeetingNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.feature.meetings.ui.create.NewMeetingViewModel skippable: false restartable: true params: + - navArgs: STABLE (class with no mutable properties) - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable diff --git a/features/sketch/build.gradle.kts b/features/sketch/build.gradle.kts index 3a276aebaf2..354fe70b31f 100644 --- a/features/sketch/build.gradle.kts +++ b/features/sketch/build.gradle.kts @@ -3,17 +3,20 @@ plugins { id(libs.plugins.wire.kover.get().pluginId) id(BuildPlugins.kotlinParcelize) id(BuildPlugins.junit5) - alias(libs.plugins.ksp) - id(libs.plugins.wire.android.navigation.get().pluginId) id(libs.plugins.wire.compose.compiler.get().pluginId) alias(libs.plugins.compose.stability.analyzer) + alias(libs.plugins.kotlin.serialization) } dependencies { + implementation(project(":core:di")) implementation(project(":core:ui-common")) + implementation(projects.core.navigation) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) + implementation(libs.compose.activity) implementation(libs.ktx.immutableCollections) + implementation(libs.ktx.serialization) val composeBom = enforcedPlatform(libs.compose.bom) implementation(composeBom) @@ -22,6 +25,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.compose.ui.preview) + implementation(libs.metrox.viewModelCompose) testImplementation(libs.junit5.core) testImplementation(libs.coroutines.test) @@ -30,10 +34,3 @@ dependencies { androidTestImplementation(libs.androidx.test.extJunit) androidTestImplementation(libs.androidx.espresso.core) } - -android { - ksp { - arg("compose-destinations.moduleName", "sketch") - arg("compose-destinations.mode", "destinations") - } -} diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasScreen.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasScreen.kt index 0fbf7d5d781..38f3c833c6c 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasScreen.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasScreen.kt @@ -17,6 +17,7 @@ */ package com.wire.android.feature.sketch +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border @@ -43,15 +44,9 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs -import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.feature.sketch.model.DrawingState import com.wire.android.feature.sketch.util.PreviewMultipleThemes import com.wire.android.model.ClickBlockParams -import com.wire.android.navigation.annotation.features.sketch.WireSketchDestination -import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.IconAlignment @@ -69,37 +64,30 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import kotlinx.coroutines.launch -@WireSketchDestination( - start = true, - style = PopUpNavigationAnimation::class, - navArgs = DrawingCanvasNavArgs::class, -) @Composable -fun DrawingCanvasScreen( - drawingCanvasNavArgs: DrawingCanvasNavArgs, - resultNavigator: ResultBackNavigator, - viewModel: DrawingCanvasViewModel = viewModel(), +internal fun DrawingCanvasRouteScreen( + conversationName: String, + tempWritableUri: Uri?, + onDismiss: () -> Unit, + onSketchSaved: (Uri) -> Unit, + viewModel: DrawingCanvasViewModel, ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val discardDrawing: () -> Unit = remember { - { - viewModel.initializeCanvas() - resultNavigator.navigateBack() - } + val discardDrawing: () -> Unit = { + viewModel.initializeCanvas() + onDismiss() } - val onDismissEvent: () -> Unit = remember { - { - if (viewModel.state.paths.isNotEmpty()) { - viewModel.onShowConfirmationDialog() - } else { - discardDrawing() - } + val onDismissEvent: () -> Unit = { + if (viewModel.state.paths.isNotEmpty()) { + viewModel.onShowConfirmationDialog() + } else { + discardDrawing() } } DrawingCanvasContent( state = viewModel.state, - title = drawingCanvasNavArgs.conversationName, + title = conversationName, onStartDrawingEvent = viewModel::onStartDrawingEvent, onDrawEvent = viewModel::onDrawEvent, onStopDrawingEvent = viewModel::onStopDrawingEvent, @@ -110,12 +98,9 @@ fun DrawingCanvasScreen( onStopDrawing = viewModel::onStopDrawing, onDismissEvent = onDismissEvent, onUndoStroke = viewModel::onUndoLastStroke, - onSendSketch = remember { - { - scope.launch { - resultNavigator.setResult(DrawingCanvasNavBackArgs(viewModel.saveImage(context))) - resultNavigator.navigateBack() - } + onSendSketch = { + scope.launch { + onSketchSaved(viewModel.saveImage(context, tempWritableUri)) } }, ) diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt index 693b637aa72..c2d79ae7392 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingCanvasViewModel.kt @@ -31,11 +31,8 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.net.toUri -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination -import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs import com.wire.android.feature.sketch.model.DrawingMotionEvent import com.wire.android.feature.sketch.model.DrawingPathProperties import com.wire.android.feature.sketch.model.DrawingState @@ -48,9 +45,7 @@ import java.io.File import java.io.FileOutputStream @Suppress("TooManyFunctions") -class DrawingCanvasViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { - - private val drawingCanvasNavArgs: DrawingCanvasNavArgs = DrawingCanvasScreenDestination.argsFrom(savedStateHandle) +class DrawingCanvasViewModel : ViewModel() { internal var state: DrawingState by mutableStateOf(DrawingState()) private set @@ -151,8 +146,8 @@ class DrawingCanvasViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { * * @return The [Uri] of the saved image. */ - suspend fun saveImage(context: Context): Uri { - val tempSketchFile = drawingCanvasNavArgs.tempWritableUri.orTempUri(context) + suspend fun saveImage(context: Context, tempWritableUri: Uri?): Uri { + val tempSketchFile = tempWritableUri.orTempUri(context) viewModelScope.launch { withContext(Dispatchers.IO) { with(state) { diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/SketchMetroViewModelBindings.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/SketchMetroViewModelBindings.kt new file mode 100644 index 00000000000..7e4e8e8d183 --- /dev/null +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/SketchMetroViewModelBindings.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.sketch + +import androidx.lifecycle.ViewModel +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ViewModelKey + +@BindingContainer +object SketchMetroViewModelBindings { + + @Provides + @IntoMap + @ViewModelKey(DrawingCanvasViewModel::class) + fun drawingCanvasViewModel(): ViewModel = DrawingCanvasViewModel() +} diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavGraph.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavGraph.kt deleted file mode 100644 index bf659545522..00000000000 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavGraph.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.sketch.navigation - -import com.ramcosta.composedestinations.annotation.ExternalModuleGraph -import com.ramcosta.composedestinations.annotation.NavGraph - -@NavGraph -annotation class SketchNavGraph( - val start: Boolean = false -) diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3.kt new file mode 100644 index 00000000000..3e9bb1a0fa5 --- /dev/null +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.sketch.navigation + +import com.wire.navigation.SessionRoute +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireNavResultContract +import com.wire.navigation.WireNavResultContractId +import com.wire.navigation.WireSessionId +import kotlinx.serialization.Serializable + +@Serializable +data class DrawingCanvasRoute( + override val sessionId: WireSessionId, + val conversationName: String, + val tempWritableUri: String?, + override val entryId: WireNavEntryId = WireNavEntryId.random(), +) : SessionRoute { + override val routeId: String + get() = ROUTE_ID + + companion object { + const val ROUTE_ID = "sketch/drawing_canvas_screen" + } +} + +@Serializable +data class DrawingCanvasResult( + val uri: String, +) + +internal val DrawingCanvasResultContract = WireNavResultContract( + WireNavResultContractId("sketch.drawing-canvas") +) diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Entries.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Entries.kt new file mode 100644 index 00000000000..49b53382312 --- /dev/null +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Entries.kt @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.sketch.navigation + +import androidx.core.net.toUri +import com.wire.android.di.metro.wireMetroViewModel +import com.wire.android.feature.sketch.DrawingCanvasRouteScreen +import com.wire.android.feature.sketch.DrawingCanvasViewModel +import com.wire.android.navigation.navigation3.WireEntryPresentation +import com.wire.android.navigation.navigation3.WireEntryProviderInstaller +import com.wire.android.navigation.navigation3.WireNavigation3ResultType +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.navigation.navigation3.wireEntry +import com.wire.navigation.WireNavResult + +val DrawingCanvasNavigation3ResultType: WireNavigation3ResultType = + WireNavigation3ResultType( + DrawingCanvasResultContract, + DrawingCanvasResult.serializer(), + ) + +object SketchNavigation3Contribution { + val resultTypes: List> = + listOf(DrawingCanvasNavigation3ResultType) + + fun entryProviderInstallers( + runtime: WireNavigation3Runtime, + ): List = listOf(sketchNavigation3Entries(runtime)) +} + +internal fun sketchNavigation3Entries( + runtime: WireNavigation3Runtime, +): WireEntryProviderInstaller = { + wireEntry(presentation = WireEntryPresentation.PopUp) { route -> + DrawingCanvasRouteScreen( + conversationName = route.conversationName, + tempWritableUri = route.tempWritableUri?.toUri(), + viewModel = wireMetroViewModel(), + onDismiss = { + if (!runtime.completeCurrentAndPop( + DrawingCanvasNavigation3ResultType, + WireNavResult.Canceled, + ) + ) { + runtime.navigator.goBack() + } + }, + onSketchSaved = { uri -> + if (!runtime.completeCurrentAndPop( + DrawingCanvasNavigation3ResultType, + WireNavResult.Value(DrawingCanvasResult(uri.toString())), + ) + ) { + runtime.navigator.goBack() + } + }, + ) + } +} diff --git a/features/sketch/src/main/java/com/wire/android/navigation/annotation/features/sketch/WireSketchDestination.kt b/features/sketch/src/main/java/com/wire/android/navigation/annotation/features/sketch/WireSketchDestination.kt deleted file mode 100644 index a38af1ea084..00000000000 --- a/features/sketch/src/main/java/com/wire/android/navigation/annotation/features/sketch/WireSketchDestination.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.navigation.annotation.features.sketch - -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.Destination.Companion.COMPOSABLE_NAME -import com.ramcosta.composedestinations.spec.DestinationStyle -import com.wire.android.feature.sketch.navigation.SketchNavGraph -import com.wire.android.navigation.wrapper.TabletDialogWrapper -import com.wire.android.navigation.wrapper.WaitUntilTransitionEndsWrapper -import kotlin.reflect.KClass - -@Destination( - wrappers = [WaitUntilTransitionEndsWrapper::class, TabletDialogWrapper::class], -) -internal annotation class WireSketchDestination( - val route: String = COMPOSABLE_NAME, - val start: Boolean = false, - val navArgs: KClass<*> = Nothing::class, - val style: KClass = DestinationStyle.Default::class, -) diff --git a/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt b/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt index 13ce7227f1f..87076f1f646 100644 --- a/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt +++ b/features/sketch/src/test/java/com/wire/android/feature/sketch/DrawingCanvasViewModelTest.kt @@ -1,21 +1,12 @@ package com.wire.android.feature.sketch -import android.net.Uri import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.lifecycle.SavedStateHandle -import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination -import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs import com.wire.android.feature.sketch.model.DrawingMotionEvent -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(NavigationTestExtension::class) class DrawingCanvasViewModelTest { @Test @@ -191,22 +182,10 @@ class DrawingCanvasViewModelTest { private class Arrangement { - @MockK - lateinit var savedStateHandle: SavedStateHandle - - @MockK - lateinit var tempWritableUri: Uri - private val viewModel by lazy { - DrawingCanvasViewModel(savedStateHandle) + DrawingCanvasViewModel() } - init { - MockKAnnotations.init(this, relaxUnitFun = true) - every { - DrawingCanvasScreenDestination.argsFrom(savedStateHandle) - } returns DrawingCanvasNavArgs("Conversation Name", tempWritableUri) - } fun arrange() = this to viewModel } diff --git a/features/sketch/src/test/java/com/wire/android/feature/sketch/NavigationTestExtension.kt b/features/sketch/src/test/java/com/wire/android/feature/sketch/NavigationTestExtension.kt deleted file mode 100644 index ee25af81ed3..00000000000 --- a/features/sketch/src/test/java/com/wire/android/feature/sketch/NavigationTestExtension.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.sketch - -import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination -import io.mockk.mockkObject -import io.mockk.mockkStatic -import io.mockk.unmockkObject -import io.mockk.unmockkStatic -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext - -/** - * This extension provides a way to mock navigation arguments getters. - * It can be used to mock getting navigation arguments from savedStateHandle, like: savedStateHandle.navArgs() - * or to get arguments from the specific Destination object, like: ExampleScreenDestination.argsFrom(savedStateHandle). - * - * Add this JUnit 5 extension to your test class using - * @JvmField - * @RegisterExtension - * val navigationTestExtension = NavigationTestExtension() - * - * or: - * - * Annotating the class with - * @ExtendWith(NavigationTestExtension::class) - */ -class NavigationTestExtension : BeforeEachCallback, AfterEachCallback { - override fun beforeEach(context: ExtensionContext?) { - mockkStatic("com.ramcosta.composedestinations.generated.sketch.NavArgsGettersKt") - mockkObject(DrawingCanvasScreenDestination) - } - - override fun afterEach(context: ExtensionContext?) { - unmockkStatic("com.ramcosta.composedestinations.generated.sketch.NavArgsGettersKt") - unmockkObject(DrawingCanvasScreenDestination) - } -} diff --git a/features/sketch/src/test/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Test.kt b/features/sketch/src/test/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Test.kt new file mode 100644 index 00000000000..f311466dc0a --- /dev/null +++ b/features/sketch/src/test/java/com/wire/android/feature/sketch/navigation/SketchNavigation3Test.kt @@ -0,0 +1,93 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.sketch.navigation + +import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireSessionId +import java.io.File +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SketchNavigation3Test { + + @Test + fun givenDrawingRoute_whenReadingRouteId_thenLegacyIdentityIsPreserved() { + assertEquals("sketch/drawing_canvas_screen", DrawingCanvasRoute.ROUTE_ID) + } + + @Test + fun givenDrawingRoute_whenSerializedAndRestored_thenArgumentsAndIdentityArePreserved() { + val route = DrawingCanvasRoute( + sessionId = WireSessionId("user", "wire.example"), + conversationName = "Design", + tempWritableUri = "content://wire/sketch.jpg", + entryId = WireNavEntryId("drawing-entry"), + ) + + assertEquals( + route, + Json.decodeFromString(Json.encodeToString(route)), + ) + } + + @Test + fun givenDrawingResult_whenSerializedAndRestored_thenUriIsPreservedAsPortableValue() { + val result = DrawingCanvasResult("content://wire/sketch.jpg") + + assertEquals( + result, + Json.decodeFromString(Json.encodeToString(result)), + ) + } + + @Test + fun givenSketchEntry_whenInspectingSource_thenItUsesTypedResultAndPopUpPresentation() { + val source = sourceFile("navigation/SketchNavigation3Entries.kt").readText() + + assertEquals(1, Regex("""wireEntry<""").findAll(source).count()) + assertTrue("WireEntryPresentation.PopUp" in source) + assertTrue("WireNavResult.Canceled" in source) + assertTrue("WireNavResult.Value" in source) + listOf( + "com.ramcosta", + "NavController", + "ResultBackNavigator", + "SavedStateHandle", + ).forEach { forbidden -> assertFalse(forbidden in source, forbidden) } + } + + @Test + fun givenSketchViewModelWiring_whenInspectingSource_thenMetroOwnsAPlatformNeutralViewModel() { + val bindingSource = sourceFile("SketchMetroViewModelBindings.kt").readText() + val viewModelSource = sourceFile("DrawingCanvasViewModel.kt").readText() + + assertTrue("@ViewModelKey(DrawingCanvasViewModel::class)" in bindingSource) + assertTrue("fun drawingCanvasViewModel(): ViewModel" in bindingSource) + listOf( + "SavedStateHandle", + "DrawingCanvasScreenDestination", + "com.ramcosta", + ).forEach { forbidden -> assertFalse(forbidden in viewModelSource, forbidden) } + } + + private fun sourceFile(name: String): File { + val relative = "src/main/java/com/wire/android/feature/sketch/$name" + return sequenceOf( + File(relative), + File("features/sketch/$relative"), + File("../features/sketch/$relative"), + ).first(File::isFile) + } +} diff --git a/features/sketch/stability/sketch-debug.stability b/features/sketch/stability/sketch-debug.stability index 8c50ee21533..065c76a0c7e 100644 --- a/features/sketch/stability/sketch-debug.stability +++ b/features/sketch/stability/sketch-debug.stability @@ -4,12 +4,6 @@ // Do not edit this file directly. To update it, run: // ./gradlew :sketch:stabilityDump -@Composable -public fun com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - @Composable private fun com.wire.android.feature.sketch.CanvasLayout(state: com.wire.android.feature.sketch.model.DrawingState, onStartDrawingEvent: kotlin.Function0, onDrawEvent: kotlin.Function0, onStopDrawingEvent: kotlin.Function0, onSizeChanged: kotlin.Function1, onStartDrawing: kotlin.Function1, onDraw: kotlin.Function1, onStopDrawing: kotlin.Function0): kotlin.Unit skippable: true @@ -68,12 +62,14 @@ internal fun com.wire.android.feature.sketch.DrawingCanvasContent(state: com.wir - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.feature.sketch.DrawingCanvasScreen(drawingCanvasNavArgs: com.wire.android.feature.sketch.model.DrawingCanvasNavArgs, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.feature.sketch.DrawingCanvasViewModel): kotlin.Unit +internal fun com.wire.android.feature.sketch.DrawingCanvasRouteScreen(conversationName: kotlin.String, tempWritableUri: android.net.Uri?, onDismiss: kotlin.Function0, onSketchSaved: kotlin.Function1, viewModel: com.wire.android.feature.sketch.DrawingCanvasViewModel): kotlin.Unit skippable: false restartable: true params: - - drawingCanvasNavArgs: RUNTIME (requires runtime check) - - resultNavigator: STABLE (marked @Stable or @Immutable) + - conversationName: STABLE (String is immutable) + - tempWritableUri: RUNTIME (requires runtime check) + - onDismiss: STABLE (function type) + - onSketchSaved: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable diff --git a/features/sync/build.gradle.kts b/features/sync/build.gradle.kts index 06eac0e43cf..fe870d60ffd 100644 --- a/features/sync/build.gradle.kts +++ b/features/sync/build.gradle.kts @@ -3,8 +3,6 @@ plugins { id(libs.plugins.wire.kover.get().pluginId) id(BuildPlugins.kotlinParcelize) id(BuildPlugins.junit5) - alias(libs.plugins.ksp) - id(libs.plugins.wire.android.navigation.get().pluginId) id(libs.plugins.wire.compose.compiler.get().pluginId) alias(libs.plugins.compose.stability.analyzer) alias(libs.plugins.kotlin.serialization) @@ -21,8 +19,6 @@ dependencies { implementation(libs.androidx.work) - // smaller view models - implementation(libs.resaca.core) implementation(libs.bundlizer.core) val composeBom = enforcedPlatform(libs.compose.bom) @@ -47,12 +43,3 @@ dependencies { androidTestImplementation(libs.androidx.test.extJunit) androidTestImplementation(libs.androidx.espresso.core) } - -android { - ksp { - // TODO: MOVE TO CONVENTION PLUGIN - // No reason to keep adding this manually to each module. We can use `project.name` - arg("compose-destinations.moduleName", "sync") - arg("compose-destinations.mode", "destinations") - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17fb3bd4a34..75606c5fa8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ libsodiumBindings = "0.9.5" detekt = "1.23.8" google-gms = "4.4.4" gms-location = "21.3.0" -android-gradlePlugin = "9.0.1" +android-gradlePlugin = "9.2.0" desugaring = "2.1.5" firebaseBOM = "34.13.0" fragment = "1.5.6" @@ -37,7 +37,7 @@ androidx-dataStore = "1.1.7" androidx-exif = "1.4.2" androidx-emoji = "1.6.0" androidx-jetpack = "1.1.0" -androidx-lifecycle = "2.10.0" +androidx-lifecycle = "2.11.0" androidx-paging3 = "3.3.6" androidx-paging3Compose = "3.3.4" androidx-splashscreen = "1.2.0" @@ -53,8 +53,7 @@ enterprise-feedback = "1.1.0" composeBom = "2026.06.00" compose-activity = "1.13.0" compose-constraint = "1.1.1" -compose-navigation = "2.9.5" # aligned with compose-destinations 2.3.0 -compose-destinations = "2.3.0" +compose-multiplatform-navigation3 = "1.1.1" compose-stability-analyzer = "0.8.0" screenshot = "0.0.1-alpha14" jetbrains-compose = "1.10.3" @@ -142,7 +141,6 @@ androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "ben wire-android-application = { id = "com.wire.android.application" } wire-android-library = { id = "com.wire.android.library" } wire-android-test-library = { id = "com.wire.android.test.library" } -wire-android-navigation = { id = "com.wire.android.navigation" } wire-compose-compiler = { id = "com.wire.android.compose.compiler" } wire-kmp-library = { id = "com.wire.android.kmp.library" } wire-kover = { id = "com.wire.android.kover" } @@ -202,6 +200,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt androidx-lifecycle-viewModel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewModelSavedState = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewModelNavigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" } # AndroidX - Other androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } @@ -242,9 +241,8 @@ jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = " # Compose other compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" } compose-constraintLayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "compose-constraint" } -compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } -compose-destinations-core = { module = "io.github.raamcosta.compose-destinations:core", version.ref = "compose-destinations" } -compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "compose-destinations" } +compose-multiplatform-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "compose-multiplatform-navigation3" } +compose-multiplatform-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "compose-multiplatform-navigation3" } # Compose Preview compose-edgetoedge-preview = { module = "de.drick.compose:edge-to-edge-preview", version.ref = "compose-edgetoedge-preview" } diff --git a/kalium b/kalium index 48bd475599d..be50fd39163 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 48bd475599dbb09ffc4989a21babaa6a31ffa147 +Subproject commit be50fd391634e477b4f4887e0d42f8ab503d1f2b diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt index 38942727560..4774d465c94 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/AccountManagement.kt @@ -181,7 +181,7 @@ class AccountManagement : BaseUiTest() { step("Bring Wire app back to foreground") { device.executeShellCommand( - "monkey -p $appPackage -c android.intent.category.LAUNCHER 1" + "am start -n $appPackage/com.wire.android.ui.WireActivity" ) } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/MultiAccountSessionScope.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/MultiAccountSessionScope.kt index 8accdec31f4..9fc61f2dcd2 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/MultiAccountSessionScope.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/MultiAccountSessionScope.kt @@ -125,19 +125,49 @@ class MultiAccountSessionScope : BaseUiTest() { @Category("regression", "RC", "multiAccountSessionScope", "TEMP") @Test fun givenSingleLoggedInAccount_whenCurrentClientIsRemovedAndUserLogsInAgain_thenMlsSessionIsRestored() { + val backendClientsBeforeAppLogin = prepareRemovedClientScenario() + establishMlsConversationBeforeRelogin() + removeClientAndLogInAgain(backendClientsBeforeAppLogin) + verifyMlsConversationAfterRelogin() + } + + private fun prepareRemovedClientScenario(): Set { + var backendClientsBeforeAppLogin = emptySet() step("Prepare staging users") { prepareTeamUsers(teamName = "SessionScopeMetroRemovedSingle") testServiceHelper.addDevice("user2Name", null, "Device1") + backendSetupHelper.userHas1on1ConversationInTeam( + "user1Name", + "user2Name", + "SessionScopeMetroRemovedSingle" + ) + backendClientsBeforeAppLogin = backendClient + .getBackendClientIds(requireNotNull(primaryUser)) + .toSet() } + step("Login the first account") { loginUser(primaryUser) } + return backendClientsBeforeAppLogin + } - step("Login the first account") { - loginUser(primaryUser) + private fun establishMlsConversationBeforeRelogin() { + step("Establish the MLS conversation before removing the current client") { + testServiceHelper.userSendMessageToPersonalMlsConversation( + "user2Name", + PRE_RELOGIN_MLS_MESSAGE, + "Device1", + "user1Name" + ) + pages.conversationListPage.tapConversationNameInConversationList(secondaryUser?.name.orEmpty()) + pages.conversationViewPage.assertReceivedMessageIsVisibleInCurrentConversation(PRE_RELOGIN_MLS_MESSAGE) + device.pressBack() + pages.conversationListPage.assertConversationListVisible() } + } + private fun removeClientAndLogInAgain(backendClientsBeforeAppLogin: Set) { step("Remove the current client from the backend") { - removeCurrentBackendClient(primaryUser) + removeCurrentBackendClient(primaryUser, excluding = backendClientsBeforeAppLogin) } - step("Confirm removed-device dialog and verify login screen opens") { pages.selfUserProfilePage.apply { assertRemovedDeviceDialogVisible() @@ -145,15 +175,15 @@ class MultiAccountSessionScope : BaseUiTest() { } pages.registrationPage.assertAuthEntryVisible() } - step("Log the removed user in again without restarting the app") { loginUser(primaryUser, configureStagingBackend = false) } + } + private fun verifyMlsConversationAfterRelogin() { step("Open the existing MLS conversation from the recreated session") { pages.conversationListPage.tapConversationNameInConversationList(secondaryUser?.name.orEmpty()) } - step("Receive an MLS message after the new client joins by external commit") { testServiceHelper.userSendMessageToPersonalMlsConversation( "user2Name", @@ -163,16 +193,12 @@ class MultiAccountSessionScope : BaseUiTest() { ) pages.conversationViewPage.assertReceivedMessageIsVisibleInCurrentConversation(POST_RELOGIN_MLS_MESSAGE) } - - step("Send an MLS message from the recreated session") { + step("Send and verify an MLS message from the recreated session") { pages.conversationViewPage.apply { typeMessageInInputField(POST_RELOGIN_REPLY) clickSendButton() assertSentMessageIsVisibleInCurrentConversation(POST_RELOGIN_REPLY) } - } - - step("Verify the test-service device receives and decrypts the MLS reply") { testServiceHelper.assertMessageReceivedInPersonalMlsConversation( receiverAlias = "user2Name", deviceName = "Device1", @@ -337,17 +363,23 @@ class MultiAccountSessionScope : BaseUiTest() { } private fun openAddAccountFlow() { - pages.conversationListPage.clickUserProfileButton() + pages.conversationListPage + .waitUntilWireServiceNotificationDisappears() + .clickUserProfileButton() pages.selfUserProfilePage.apply { iSeeUserProfilePage() tapNewAccountButton() } } - private fun removeCurrentBackendClient(user: ClientUser?) { + private fun removeCurrentBackendClient(user: ClientUser?, excluding: Set = emptySet()) { val clientUser = requireNotNull(user) { "Cannot remove backend client for a missing user." } - val clientId = backendClient.getBackendClientIds(clientUser).singleOrNull() - ?: throw AssertionError("Expected exactly one backend client for ${clientUser.name}.") + val candidateClientIds = backendClient.getBackendClientIds(clientUser) - excluding + val clientId = candidateClientIds.singleOrNull() + ?: throw AssertionError( + "Expected exactly one new backend client for ${clientUser.name}, " + + "but found ${candidateClientIds.size}: $candidateClientIds." + ) backendClient.removeBackendClient(clientUser, clientId) } @@ -377,6 +409,7 @@ class MultiAccountSessionScope : BaseUiTest() { private companion object { const val EXISTING_CLIENTS_LIMIT = 7 + const val PRE_RELOGIN_MLS_MESSAGE = "MLS before client re-registration" const val POST_RELOGIN_MLS_MESSAGE = "MLS after client re-registration" const val POST_RELOGIN_REPLY = "MLS reply after client re-registration" val POST_REMOVE_DEVICE_LOGIN_TIMEOUT = 120.seconds diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt index c91e30402d5..4fffa07cf3f 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt @@ -44,7 +44,6 @@ data class ConnectedUserProfilePage(private val device: UiDevice) { private val removeConversationButtonOnModal = UiSelectorParams(text = "Remove") private val closeButton = UiSelectorParams( - className = "android.view.View", description = "Close" ) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt index e88e0670faf..2d0a999bef8 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt @@ -34,8 +34,9 @@ import kotlin.time.Duration data class ConversationListPage(private val device: UiDevice) { private val searchField = UiSelectorParams(description = "Search conversations") - private val userProfileButtonNoPhoto = UiSelectorParams(description = "Your profile") - private val userProfileButton = UiSelectorParams(resourceId = "User avatar") + private val userProfileButton = UiSelectorParams(description = "Your profile") + private val userProfileButtonWithNotification = + UiSelectorParams(description = "Your profile, one unread hint.") private val conversationListHeading = UiSelectorParams( textContains = "Conversations" ) @@ -509,7 +510,6 @@ data class ConversationListPage(private val device: UiDevice) { fun clickCloseButtonOnNewConversationScreen(timeout: Duration = UiWaitUtils.SHORT_TIMEOUT): ConversationListPage { val closeButton = UiSelectorParams( - className = "android.view.View", description = "Close new conversation view" ) @@ -579,22 +579,18 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun clickUserProfileButton(): ConversationListPage { - val buttonWithPhoto = UiWaitUtils.findElementOrNull(userProfileButton) - if (buttonWithPhoto != null && !buttonWithPhoto.visibleBounds.isEmpty) { - buttonWithPhoto.click() - } else { - val buttonNoPhoto = UiWaitUtils.waitElement(userProfileButtonNoPhoto) - buttonNoPhoto.click() + fun clickUserProfileButton(): ConversationListPage { + val button = UiWaitUtils.waitAnyVisible( + listOf(userProfileButton, userProfileButtonWithNotification) + ) ?: throw AssertionError("Self user profile button is not displayed") + button.click() + return this } - return this -} fun clickUserProfileButtonNoPhoto(): ConversationListPage { - val buttonNoPhoto = UiWaitUtils.waitElement(userProfileButtonNoPhoto) - buttonNoPhoto.click() - return this -} + UiWaitUtils.waitElement(userProfileButton).click() + return this + } fun assertConversationIsVisibleWithTeamOwner(userName: String): ConversationListPage { try { diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index 462dc5101bd..6ea4193b7e1 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt @@ -528,9 +528,10 @@ data class ConversationViewPage(private val device: UiDevice) { private fun isConversationViewStillVisible(): Boolean { return try { + val backButtonVisible = findElementOrNull(backButton)?.let { !it.visibleBounds.isEmpty } == true val typeMessageVisible = findElementOrNull(typeMessageField)?.let { !it.visibleBounds.isEmpty } == true val sendButtonVisible = findElementOrNull(sendButton)?.let { !it.visibleBounds.isEmpty } == true - typeMessageVisible || sendButtonVisible + backButtonVisible || typeMessageVisible || sendButtonVisible } catch (_: StaleObjectException) { false } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt index 50339fe9204..64f2dcb9253 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SearchPage.kt @@ -25,7 +25,6 @@ import user.usermanager.ClientUserManager data class SearchPage(private val device: UiDevice) { private val searchFieldSearchPeople = UiSelectorParams(description = "Search people by name or username") private val closeSearchInputFieldButton = UiSelectorParams( - className = "android.view.View", description = "Go back to add participants view" ) diff --git a/tests/testsSupport/src/main/service/TestService.kt b/tests/testsSupport/src/main/service/TestService.kt index 04e96489c7c..20545b2b761 100644 --- a/tests/testsSupport/src/main/service/TestService.kt +++ b/tests/testsSupport/src/main/service/TestService.kt @@ -883,7 +883,9 @@ class TestService(private val baseUri: String, private val testName: String) { for (i in 0 until messages.length()) { val message = messages.getJSONObject(i) val content = message.optJSONObject("content") - if (content != null && content.optString("text") == text) { + val messageText = content?.optString("text")?.takeIf(String::isNotEmpty) + ?: content?.optString("value") + if (messageText == text) { return message.getString("id") } } From 9252ba2cb27526f0bd3eaf9ce19d3a1c4311e7d8 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 18 Aug 2026 09:52:53 +0200 Subject: [PATCH 02/13] fix: stabilize Navigation 3 CI checks [MOB-20] --- .../com/wire/android/WireActivityTest.kt | 5 +- .../com/wire/android/ui/WireActivity.kt | 4 + app/stability/app-devDebug.stability | 2355 +++++++++-------- .../com/wire/android/di/ViewModelScoped.kt | 5 +- .../navigation3/WireNavigation3Runtime.kt | 2 +- .../ui/create/NewMeetingViewModelTest.kt | 17 +- 6 files changed, 1315 insertions(+), 1073 deletions(-) diff --git a/app/src/androidTest/kotlin/com/wire/android/WireActivityTest.kt b/app/src/androidTest/kotlin/com/wire/android/WireActivityTest.kt index 804454ea9c1..b3286987211 100644 --- a/app/src/androidTest/kotlin/com/wire/android/WireActivityTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/WireActivityTest.kt @@ -25,11 +25,10 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.Lifecycle import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.work.testing.WorkManagerTestInitHelper -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModelProvider import co.touchlab.kermit.platformLogWriter import com.wire.android.extensions.performClickWithNodeWithText import com.wire.android.extensions.waitUntilExists @@ -98,7 +97,7 @@ class WireActivityTest { } returns SwitchAccountResult.NoOtherAccountToSwitch composeTestRule.runOnIdle { - val viewModel = ViewModelProvider(composeTestRule.activity)[WireActivityViewModel::class.java] + val viewModel = composeTestRule.activity.viewModelForTest() viewModel.setAccountSwitchForTest(accountSwitch) viewModel.setGlobalAppStateForTest( viewModel.globalAppState.copy(blockUserUI = CurrentSessionErrorState.RemovedClient) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 7b0f6601c0a..70c7ed953ae 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -25,6 +25,7 @@ import android.util.Log import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf @@ -98,6 +99,9 @@ class WireActivity : BaseActivity() { ) } + @VisibleForTesting + internal fun viewModelForTest(): WireActivityViewModel = viewModel + private val intentCoordinator = WireActivityIntentCoordinator() private lateinit var shakeDetector: ShakeDetector diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index b69e69a1090..f5d8d5181d0 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -14,613 +14,220 @@ internal fun MutingOptionsSheetContent(mutingConversationState: com.wire.kalium. - onBackClick: STABLE (function type) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.AboutThisAppScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.AddMembersSearchScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.AllConversationsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.AppSettingsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.AppUnlockWithBiometricsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ArchiveScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.AvatarPickerScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.BackupAndRestoreScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.BrowseChannelsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChangeDisplayNameScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChangeEmailScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChangeHandleScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChangeUserColorScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnCreateScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnUpdateScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryCustomScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ConversationCryptoStatsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ConversationFoldersScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ConversationMediaScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountCodeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDataDetailScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountDetailsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountEmailScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSelectorScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountSummaryScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountUsernameScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateAccountVerificationCodeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreatePasswordProtectedGuestLinkScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreatePersonalAccountOverviewScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CreateTeamAccountOverviewScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.CustomizationScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.DebugConversationScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.DebugFeatureFlagsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.DebugScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.DependenciesScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.DeviceDetailsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.E2EIEnrollmentScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.E2EiCertificateDetailsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.EditConversationNameScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.EditGuestAccessScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.EditSelfDeletingMessagesScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.EnterLockCodeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ForgotLockCodeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.GlobalCellsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.GroupConversationAllParticipantsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.GroupConversationDetailsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.GroupOptionScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ImagesPreviewScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ImportMediaScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.InitialSyncScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.LicensesScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.LogManagementScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.LoginScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.MediaGalleryScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.MeetingsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.MessageDetailsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.MyAccountScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NetworkSettingsScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewConversationFolderScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewGroupNameScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewLoginScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewLoginVerificationCodeScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.NewWelcomeEmptyStartScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.PrivacySettingsConfigScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.RegisterDeviceScreenDestination.Content(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.RemoveDeviceScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountCodeNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountCodeRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.SearchConversationMessagesScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountDataDetailNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountDataDetailRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.SelfDevicesScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountDetailsNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountDetailsRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.SelfQRCodeScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountEmailNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountEmailRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountOverviewNavigation3Entry(route: com.wire.navigation.AuthenticationRoute, flowType: com.wire.android.ui.authentication.create.common.CreateAccountFlowType, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: RUNTIME (requires runtime check) + - flowType: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountSelectorNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountSelectorRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.SetLockCodeScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountSummaryNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountSummaryRoute, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.SettingsScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountUsernameNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountUsernameRoute, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationConfirmationStepScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.CreateAccountVerificationNavigation3Entry(route: com.wire.android.navigation.routes.auth.CreateAccountVerificationCodeRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationDoneStepScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.LoginNavigation3Entry(route: com.wire.android.navigation.routes.auth.LoginRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationTeamNameStepScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.NewLoginNavigation3Entry(route: com.wire.android.navigation.routes.auth.NewLoginRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.TeamMigrationTeamPlanStepScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.NewLoginPasswordNavigation3Entry(route: com.wire.android.navigation.routes.auth.NewLoginPasswordRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.UpdateAppsAccessScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.NewLoginVerificationCodeNavigation3Entry(route: com.wire.android.navigation.routes.auth.NewLoginVerificationCodeRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.VaultScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.auth.WelcomeNavigation3Entry(route: com.wire.android.navigation.routes.auth.WelcomeRoute, actions: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Actions, router: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - router: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.VerifyEmailScreenDestination.Content(): kotlin.Unit +private fun com.wire.android.navigation.routes.auth.createAccountEntryOwner(entryId: com.wire.navigation.WireNavEntryId): androidx.lifecycle.ViewModelStoreOwner skippable: true restartable: true params: + - entryId: STABLE (class with no mutable properties) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.VideoPlayerScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.media.AuthenticatedImportMediaNavigation3Entry(route: com.wire.android.navigation.routes.media.AuthenticatedImportMediaRoute, actions: com.wire.android.navigation.routes.media.MediaNavigation3Actions): kotlin.Unit + skippable: false restartable: true params: + - route: RUNTIME (requires runtime check) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.WelcomeChooserScreenDestination.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.navigation.routes.media.MediaGalleryNavigation3Entry(route: com.wire.android.navigation.routes.media.MediaGalleryRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.navigation.routes.media.MediaNavigation3Actions): kotlin.Unit + skippable: false restartable: true params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.WelcomeScreenDestination.Content(): kotlin.Unit - skippable: true +public fun com.wire.android.navigation.runtime.MetroWireEntryEnvironment.Provide(route: com.wire.navigation.WireRoute, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit + skippable: false restartable: true params: + - route: RUNTIME (requires runtime check) + - content: STABLE (composable function type) @Composable -public fun com.ramcosta.composedestinations.generated.app.destinations.WhatsNewScreenDestination.Content(): kotlin.Unit - skippable: true +internal fun com.wire.android.navigation.runtime.ObserveNavigation3Routes(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, currentScreenManager: com.wire.android.util.CurrentScreenManager, onRouteChanged: kotlin.Function0): kotlin.Unit + skippable: false restartable: true params: + - runtime: STABLE (marked @Stable or @Immutable) + - currentScreenManager: UNSTABLE (has mutable properties or unstable members) + - onRouteChanged: STABLE (function type) @Composable -public fun com.wire.android.navigation.AdjustDestinationStylesForTablets(): kotlin.Unit +internal fun com.wire.android.navigation.runtime.ProvideViewModelGraph(logoutAction: kotlin.Function1<@[ParameterName(name = \, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true restartable: true params: + - logoutAction: STABLE (function type) + - content: STABLE (composable function type) @Composable -public fun com.wire.android.navigation.MainNavHost(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector?, startDestination: com.ramcosta.composedestinations.spec.Direction, modifier: androidx.compose.ui.Modifier): kotlin.Unit +internal fun com.wire.android.navigation.runtime.WireNavigation3ProductionHost(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.navigation.runtime.WireNavigation3CompositeActions, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router, entryEnvironment: com.wire.android.navigation.navigation3.WireEntryEnvironment, onRootBack: kotlin.Function0, sharedViewModelStoreProvider: androidx.lifecycle.viewmodel.ViewModelStoreProvider, modifier: androidx.compose.ui.Modifier, responsivePresentationPolicy: com.wire.android.navigation.navigation3.WireResponsivePresentationPolicy): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) - - startDestination: RUNTIME (requires runtime check) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) + - entryEnvironment: RUNTIME (requires runtime check) + - onRootBack: STABLE (function type) + - sharedViewModelStoreProvider: UNSTABLE (has mutable properties or unstable members) - modifier: STABLE (marked @Stable or @Immutable) + - responsivePresentationPolicy: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.navigation.WireNavHostEngine.NavHost(modifier: androidx.compose.ui.Modifier, route: kotlin.String, start: com.ramcosta.composedestinations.spec.Direction, defaultTransitions: com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle, navController: androidx.navigation.NavHostController, builder: @[ExtensionFunctionType] kotlin.Function1): kotlin.Unit +internal fun com.wire.android.navigation.runtime.rememberWireNavigation3ActivityGraphContext(route: com.wire.navigation.WireRoute?, graphResolver: com.wire.android.navigation.runtime.MetroWireEntryGraphResolver, isUserUiBlocked: kotlin.Boolean): com.wire.android.navigation.runtime.WireActivityGraphContext? skippable: false restartable: true params: - - modifier: STABLE (marked @Stable or @Immutable) - - route: STABLE (String is immutable) - - start: RUNTIME (requires runtime check) - - defaultTransitions: RUNTIME (requires runtime check) - - navController: UNSTABLE (has mutable properties or unstable members) - - builder: STABLE (function type) + - route: RUNTIME (requires runtime check) + - graphResolver: UNSTABLE (has mutable properties or unstable members) + - isUserUiBlocked: STABLE (primitive type) @Composable -public fun com.wire.android.navigation.WireNavHostEngine.rememberNavController(navigators: kotlin.Array>): androidx.navigation.NavHostController +internal fun com.wire.android.navigation.runtime.rememberWireNavigation3ProductionActions(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, activity: com.wire.android.navigation.runtime.WireNavigation3ActivityCallbacks, currentSessionId: kotlin.Function0, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): com.wire.android.navigation.runtime.WireNavigation3ProductionActions skippable: false restartable: true params: - - navigators: UNSTABLE (has mutable properties or unstable members) - -@Composable -public fun com.wire.android.navigation.rememberNavigator(isAllowedToNavigate: kotlin.Function1, finish: kotlin.Function0): com.wire.android.navigation.Navigator - skippable: true - restartable: true - params: - - isAllowedToNavigate: STABLE (function type) - - finish: STABLE (function type) - -@Composable -public fun com.wire.android.navigation.rememberTrackingAnimatedNavController(nameFromRoute: kotlin.Function1): androidx.navigation.NavHostController - skippable: true - restartable: true - params: - - nameFromRoute: STABLE (function type) + - runtime: STABLE (marked @Stable or @Immutable) + - activity: STABLE (class with no mutable properties) + - currentSessionId: STABLE (function type) + - loginTypeSelector: RUNTIME (requires runtime check) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) @Composable -internal fun com.wire.android.navigation.rememberWireNavHostEngine(navHostContentAlignment: androidx.compose.ui.Alignment): com.ramcosta.composedestinations.spec.NavHostEngine - skippable: true +internal fun com.wire.android.navigation.runtime.wireSessionGraphStoreViewModel(appGraph: com.wire.android.di.metro.WireApplicationGraph, owner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.navigation.runtime.SessionGraphStoreViewModel + skippable: false restartable: true params: - - navHostContentAlignment: STABLE (marked @Stable or @Immutable) + - appGraph: RUNTIME (requires runtime check) + - owner: RUNTIME (requires runtime check) @Composable public fun com.wire.android.ui.AccountLoggedOutDialog(blockUserUI: com.wire.android.ui.CurrentSessionErrorState?, navigateAway: kotlin.Function0): kotlin.Unit @@ -638,6 +245,15 @@ private fun com.wire.android.ui.AccountLoggedOutDialogContent(reason: com.wire.a - reason: STABLE (class with no mutable properties) - navigateAway: STABLE (function type) +@Composable +private fun com.wire.android.ui.AppLockActivity.AppLockNavigation3Root(appGraph: com.wire.android.di.metro.WireApplicationGraph, startRoute: com.wire.navigation.SessionRoute, sessionGraphStore: com.wire.android.navigation.runtime.SessionGraphStoreViewModel): kotlin.Unit + skippable: false + restartable: true + params: + - appGraph: RUNTIME (requires runtime check) + - startRoute: RUNTIME (requires runtime check) + - sessionGraphStore: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.CallFeedbackDialog(sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, onRated: kotlin.Function2, onSkipClicked: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false @@ -690,21 +306,52 @@ public fun com.wire.android.ui.GuestRoomLinkFeatureFlagDialog(isGuestRoomLinkEna - onDismiss: STABLE (function type) @Composable -internal fun com.wire.android.ui.HandleViewActions(actions: kotlinx.coroutines.flow.Flow, navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector): kotlin.Unit +internal fun com.wire.android.ui.HandleNavigation3SessionEffects(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router, currentUserId: com.wire.navigation.WireSessionId?, isUserUiBlocked: kotlin.Boolean, isSessionTransitionInProgress: kotlin.Boolean, isSelfLogoutTransition: kotlin.Boolean, finishSessionTransition: kotlin.Function0, resolveMissingCurrentSession: kotlin.Function0): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) + - currentUserId: STABLE (class with no mutable properties) + - isUserUiBlocked: STABLE (primitive type) + - isSessionTransitionInProgress: STABLE (primitive type) + - isSelfLogoutTransition: STABLE (primitive type) + - finishSessionTransition: STABLE (function type) + - resolveMissingCurrentSession: STABLE (function type) + +@Composable +internal fun com.wire.android.ui.HandleNavigation3ViewActions(actions: kotlinx.coroutines.flow.Flow, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, currentSessionId: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - actions: RUNTIME (requires runtime check) - - navigator: STABLE (marked @Stable or @Immutable) + - runtime: STABLE (marked @Stable or @Immutable) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) - loginTypeSelector: RUNTIME (requires runtime check) + - currentSessionId: STABLE (function type) + +@Composable +private fun com.wire.android.ui.HandleWireActivityScreenshotCensoring(dependencies: com.wire.android.ui.WireActivityHostDependencies): kotlin.Unit + skippable: false + restartable: true + params: + - dependencies: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.JoinConversationDialog(joinedDialogState: com.wire.android.ui.joinConversation.JoinConversationViaCodeState?, navigate: kotlin.Function1, onJoinConversationFlowCompleted: kotlin.Function0): kotlin.Unit +private fun com.wire.android.ui.HandleWireActivityThemeChanges(themeOption: com.wire.android.ui.theme.ThemeOption): kotlin.Unit skippable: true restartable: true + params: + - themeOption: STABLE (class with no mutable properties) + +@Composable +public fun com.wire.android.ui.JoinConversationDialog(joinedDialogState: com.wire.android.ui.joinConversation.JoinConversationViaCodeState?, openConversation: kotlin.Function1, viewModel: com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel, onJoinConversationFlowCompleted: kotlin.Function0): kotlin.Unit + skippable: false + restartable: true params: - joinedDialogState: STABLE (class with no mutable properties) - - navigate: STABLE (function type) + - openConversation: STABLE (function type) + - viewModel: UNSTABLE (has mutable properties or unstable members) - onJoinConversationFlowCompleted: STABLE (function type) @Composable @@ -752,107 +399,63 @@ public fun com.wire.android.ui.UpdateAppDialog(shouldShow: kotlin.Boolean, onUpd - onUpdateClick: STABLE (function type) @Composable -private fun com.wire.android.ui.WireActivity.HandleDialogs(navigator: com.wire.android.navigation.Navigator, activityViewModels: com.wire.android.ui.WireActivityScopedViewModels?): kotlin.Unit +internal fun com.wire.android.ui.WireActivityDialogs(viewModel: com.wire.android.ui.WireActivityViewModel, activityViewModels: com.wire.android.ui.WireActivityScopedViewModels?, actions: com.wire.android.ui.WireActivityDialogActions): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - viewModel: UNSTABLE (has mutable properties or unstable members) - activityViewModels: UNSTABLE (has mutable properties or unstable members) + - actions: STABLE (class with no mutable properties) @Composable -private fun com.wire.android.ui.WireActivity.HandleScreenshotCensoring(): kotlin.Unit - skippable: true - restartable: true - params: - -@Composable -private fun com.wire.android.ui.WireActivity.HandleSessionGraphEffects(currentUserId: com.wire.kalium.logic.data.id.QualifiedID?, sessionBackedAuthenticationUserId: com.wire.kalium.logic.data.id.QualifiedID?, currentBaseRoute: kotlin.String?, isAuthenticationRoute: kotlin.Boolean, isUserUiBlocked: kotlin.Boolean, isSessionTransitionInProgress: kotlin.Boolean, graphContext: com.wire.android.ui.WireActivityGraphContext?, navigator: com.wire.android.navigation.Navigator): kotlin.Unit +internal fun com.wire.android.ui.WireActivityNavigation3Host(startDestination: com.wire.navigation.WireRoute, dependencies: com.wire.android.ui.WireActivityHostDependencies): kotlin.Unit skippable: false restartable: true params: - - currentUserId: UNSTABLE (has mutable properties or unstable members) - - sessionBackedAuthenticationUserId: UNSTABLE (has mutable properties or unstable members) - - currentBaseRoute: STABLE (class with no mutable properties) - - isAuthenticationRoute: STABLE (primitive type) - - isUserUiBlocked: STABLE (primitive type) - - isSessionTransitionInProgress: STABLE (primitive type) - - graphContext: UNSTABLE (has mutable properties or unstable members) - - navigator: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.wire.android.ui.WireActivity.HandleThemeChanges(themeOption: com.wire.android.ui.theme.ThemeOption): kotlin.Unit - skippable: true - restartable: true - params: - - themeOption: STABLE (class with no mutable properties) - -@Composable -private fun com.wire.android.ui.WireActivity.ProvideViewModelGraph(logoutAction: kotlin.Function1<@[ParameterName(name = \, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit - skippable: true - restartable: true - params: - - logoutAction: STABLE (function type) - - content: STABLE (composable function type) - -@Composable -private fun com.wire.android.ui.WireActivity.SetUpNavigation(navigator: com.wire.android.navigation.Navigator): kotlin.Unit - skippable: true - restartable: true - params: - - navigator: STABLE (marked @Stable or @Immutable) + - startDestination: RUNTIME (requires runtime check) + - dependencies: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.WireActivity.WireActivityMainContent(startDestination: com.ramcosta.composedestinations.spec.Direction, navigator: com.wire.android.navigation.Navigator, graphContext: com.wire.android.ui.WireActivityGraphContext?, backgroundType: com.wire.android.navigation.style.BackgroundType, context: android.content.Context): kotlin.Unit +private fun com.wire.android.ui.WireActivityNavigation3MainContent(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.navigation.runtime.WireNavigation3ProductionActions, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router, entryEnvironment: com.wire.android.navigation.runtime.MetroWireEntryEnvironment, graphContext: com.wire.android.navigation.runtime.WireActivityGraphContext?, backgroundType: com.wire.android.navigation.style.BackgroundType, dependencies: com.wire.android.ui.WireActivityHostDependencies, switchAccountActions: com.wire.android.feature.SwitchAccountActions, activitySessionViewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner?, sharedViewModelStoreProvider: androidx.lifecycle.viewmodel.ViewModelStoreProvider): kotlin.Unit skippable: false restartable: true params: - - startDestination: RUNTIME (requires runtime check) - - navigator: STABLE (marked @Stable or @Immutable) - - graphContext: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: UNSTABLE (has mutable properties or unstable members) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) + - entryEnvironment: UNSTABLE (has mutable properties or unstable members) + - graphContext: RUNTIME (requires runtime check) - backgroundType: STABLE (class with no mutable properties) - - context: RUNTIME (requires runtime check) + - dependencies: UNSTABLE (has mutable properties or unstable members) + - switchAccountActions: RUNTIME (requires runtime check) + - activitySessionViewModelStoreOwner: RUNTIME (requires runtime check) + - sharedViewModelStoreProvider: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.WireActivity.WireActivityRoot(startDestination: com.ramcosta.composedestinations.spec.Direction, appGraph: com.wire.android.di.metro.WireApplicationGraph, sessionGraphStore: com.wire.android.ui.SessionGraphStoreViewModel): kotlin.Unit +internal fun com.wire.android.ui.WireActivityNavigation3Setup(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, intentCoordinator: com.wire.android.navigation.runtime.WireActivityIntentCoordinator, currentScreenManager: com.wire.android.util.CurrentScreenManager, switchAccountObserver: com.wire.android.util.SwitchAccountObserver, switchAccountActions: com.wire.android.feature.SwitchAccountActions, shakeEvents: kotlinx.coroutines.flow.Flow, onIntentRequest: kotlin.coroutines.SuspendFunction1, onShake: kotlin.coroutines.SuspendFunction0, lifecycleOwner: androidx.lifecycle.LifecycleOwner): kotlin.Unit skippable: false restartable: true params: - - startDestination: RUNTIME (requires runtime check) - - appGraph: RUNTIME (requires runtime check) - - sessionGraphStore: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - intentCoordinator: RUNTIME (requires runtime check) + - currentScreenManager: UNSTABLE (has mutable properties or unstable members) + - switchAccountObserver: UNSTABLE (has mutable properties or unstable members) + - switchAccountActions: RUNTIME (requires runtime check) + - shakeEvents: RUNTIME (requires runtime check) + - onIntentRequest: STABLE (function type) + - onShake: STABLE (function type) + - lifecycleOwner: RUNTIME (requires runtime check) @Composable -private fun com.wire.android.ui.WireActivity.WireActivityThemedContent(startDestination: com.ramcosta.composedestinations.spec.Direction, appGraph: com.wire.android.di.metro.WireApplicationGraph, authenticationViewModelGraph: com.wire.android.di.metro.AppAuthenticationViewModelGraph, sessionGraphStore: com.wire.android.ui.SessionGraphStoreViewModel, context: android.content.Context): kotlin.Unit +private fun com.wire.android.ui.WireActivityNavigation3ThemedHost(startDestination: com.wire.navigation.WireRoute, dependencies: com.wire.android.ui.WireActivityHostDependencies, authenticationViewModelGraph: com.wire.android.di.metro.AppAuthenticationViewModelGraph, sessionGraphStore: com.wire.android.navigation.runtime.SessionGraphStoreViewModel, sharedViewModelStoreProvider: androidx.lifecycle.viewmodel.ViewModelStoreProvider): kotlin.Unit skippable: false restartable: true params: - startDestination: RUNTIME (requires runtime check) - - appGraph: RUNTIME (requires runtime check) - - authenticationViewModelGraph: STABLE (class with no mutable properties) - - sessionGraphStore: UNSTABLE (has mutable properties or unstable members) - - context: RUNTIME (requires runtime check) - -@Composable -private fun com.wire.android.ui.WireActivity.rememberWireActivityGraphContext(appGraph: com.wire.android.di.metro.WireApplicationGraph, authenticationViewModelGraph: com.wire.android.di.metro.AppAuthenticationViewModelGraph, sessionGraphStore: com.wire.android.ui.SessionGraphStoreViewModel, currentUserId: com.wire.kalium.logic.data.id.QualifiedID?, sessionBackedAuthenticationUserId: com.wire.kalium.logic.data.id.QualifiedID?, currentBaseRoute: kotlin.String?, startDestinationBaseRoute: kotlin.String, isUserUiBlocked: kotlin.Boolean, isSessionTransitionInProgress: kotlin.Boolean): com.wire.android.ui.WireActivityGraphContext? - skippable: false - restartable: true - params: - - appGraph: RUNTIME (requires runtime check) + - dependencies: UNSTABLE (has mutable properties or unstable members) - authenticationViewModelGraph: STABLE (class with no mutable properties) - sessionGraphStore: UNSTABLE (has mutable properties or unstable members) - - currentUserId: UNSTABLE (has mutable properties or unstable members) - - sessionBackedAuthenticationUserId: UNSTABLE (has mutable properties or unstable members) - - currentBaseRoute: STABLE (class with no mutable properties) - - startDestinationBaseRoute: STABLE (String is immutable) - - isUserUiBlocked: STABLE (primitive type) - - isSessionTransitionInProgress: STABLE (primitive type) - -@Composable -private fun com.wire.android.ui.WireActivity.wireActivityScopedViewModels(retainedSessionGraph: com.wire.android.ui.RetainedSessionGraph): com.wire.android.ui.WireActivityScopedViewModels - skippable: true - restartable: true - params: - - retainedSessionGraph: STABLE (marked @Stable or @Immutable) + - sharedViewModelStoreProvider: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.analyticsUsageViewModel(): com.wire.android.ui.analytics.AnalyticsUsageViewModel @@ -882,26 +485,18 @@ public fun com.wire.android.ui.authentication.MissingBackendConfigContent(modifi - onConfigurationLinkEntered: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.authenticationViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, key: kotlin.String?): VM of com.wire.android.ui.authentication.authenticationViewModel +public fun com.wire.android.ui.authentication.authenticationViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): VM of com.wire.android.ui.authentication.authenticationViewModel skippable: false restartable: true params: - viewModelStoreOwner: RUNTIME (requires runtime check) - - key: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.authentication.authenticationViewModelKey(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, key: kotlin.String?): kotlin.String? +public fun com.wire.android.ui.authentication.clearSessionViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.devices.common.ClearSessionViewModel skippable: false restartable: true params: - viewModelStoreOwner: RUNTIME (requires runtime check) - - key: STABLE (class with no mutable properties) - -@Composable -public fun com.wire.android.ui.authentication.clearSessionViewModel(): com.wire.android.ui.authentication.devices.common.ClearSessionViewModel - skippable: true - restartable: true - params: @Composable private fun com.wire.android.ui.authentication.create.code.CodeContent(state: com.wire.android.ui.authentication.create.code.CreateAccountCodeViewState, textState: androidx.compose.foundation.text.input.TextFieldState, onResendCodePressed: kotlin.Function0, onBackPressed: kotlin.Function0, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links): kotlin.Unit @@ -915,12 +510,14 @@ private fun com.wire.android.ui.authentication.create.code.CodeContent(state: co - serverConfig: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.authentication.create.code.CreateAccountCodeScreen(navigator: com.wire.android.navigation.Navigator, createAccountCodeViewModel: com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.create.code.CreateAccountCodeRouteScreen(viewModel: com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel, onNavigateBack: kotlin.Function0, onSuccess: kotlin.Function2, onTooManyDevices: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - createAccountCodeViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onSuccess: STABLE (function type) + - onTooManyDevices: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.create.code.getResources(type: com.wire.android.ui.authentication.create.common.CreateAccountFlowType): com.wire.android.util.DialogErrorStrings @@ -961,12 +558,13 @@ public fun com.wire.android.ui.authentication.create.common.handle.UsernameTextF - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.authentication.create.details.CreateAccountDetailsScreen(navigator: com.wire.android.navigation.Navigator, createAccountDetailsViewModel: com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.create.details.CreateAccountDetailsRouteScreen(viewModel: com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel, onNavigateBack: kotlin.Function0, onCodeRequested: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - createAccountDetailsViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onCodeRequested: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.create.details.DetailsContent(state: com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewState, firstNameTextState: androidx.compose.foundation.text.input.TextFieldState, lastNameTextState: androidx.compose.foundation.text.input.TextFieldState, passwordTextState: androidx.compose.foundation.text.input.TextFieldState, confirmPasswordTextState: androidx.compose.foundation.text.input.TextFieldState, teamNameTextState: androidx.compose.foundation.text.input.TextFieldState, onBackPressed: kotlin.Function0, onContinuePressed: kotlin.Function0, onErrorDismiss: kotlin.Function0, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links): kotlin.Unit @@ -985,12 +583,14 @@ private fun com.wire.android.ui.authentication.create.details.DetailsContent(sta - serverConfig: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.authentication.create.email.CreateAccountEmailScreen(navigator: com.wire.android.navigation.Navigator, createAccountEmailViewModel: com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.create.email.CreateAccountEmailRouteScreen(viewModel: com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel, onNavigateBack: kotlin.Function0, onLogin: kotlin.Function0, onDetailsRequested: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - createAccountEmailViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onLogin: STABLE (function type) + - onDetailsRequested: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.create.email.EmailContent(state: com.wire.android.ui.authentication.create.email.CreateAccountEmailViewState, emailTextState: androidx.compose.foundation.text.input.TextFieldState, onBackPressed: kotlin.Function0, onContinuePressed: kotlin.Function0, onLoginPressed: kotlin.Function0, onTermsDialogDismiss: kotlin.Function0, onTermsAccept: kotlin.Function0, onErrorDismiss: kotlin.Function0, tosUrl: kotlin.String, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links): kotlin.Unit @@ -1034,136 +634,192 @@ private fun com.wire.android.ui.authentication.create.email.TermsConditionsDialo - onViewPolicyPressed: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.create.overview.CreateAccountOverviewScreen(navigator: com.wire.android.navigation.Navigator, flowType: com.wire.android.ui.authentication.create.common.CreateAccountFlowType, viewModel: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.create.overview.CreateAccountOverviewRouteScreen(flowType: com.wire.android.ui.authentication.create.common.CreateAccountFlowType, viewModel: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel, onNavigateBack: kotlin.Function0, onContinue: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - flowType: STABLE (class with no mutable properties) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onContinue: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.create.overview.CreatePersonalAccountOverviewScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel): kotlin.Unit +private fun com.wire.android.ui.authentication.create.overview.OverviewContent(onBackPressed: kotlin.Function0, onContinuePressed: kotlin.Function0, overviewParams: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewParams, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onContinuePressed: STABLE (function type) + - overviewParams: STABLE (class with no mutable properties) + - serverConfig: UNSTABLE (has mutable properties or unstable members) + +@Composable +private fun com.wire.android.ui.authentication.create.overview.OverviewTexts(overviewParams: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewParams, modifier: androidx.compose.ui.Modifier, onLearnMoreClick: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - overviewParams: STABLE (class with no mutable properties) + - modifier: STABLE (marked @Stable or @Immutable) + - onLearnMoreClick: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.create.overview.CreateTeamAccountOverviewScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.create.summary.CreateAccountSummaryRouteScreen(viewModel: com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel, onContinue: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onContinue: STABLE (function type) @Composable -private fun com.wire.android.ui.authentication.create.overview.OverviewContent(onBackPressed: kotlin.Function0, onContinuePressed: kotlin.Function0, overviewParams: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewParams, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links): kotlin.Unit +private fun com.wire.android.ui.authentication.create.summary.SummaryContent(state: com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewState, onContinuePressed: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - state: STABLE (class with no mutable properties) + - onContinuePressed: STABLE (function type) + +@Composable +internal fun com.wire.android.ui.authentication.create.username.CreateAccountUsernameRouteScreen(viewModel: com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel, onSuccess: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - onBackPressed: STABLE (function type) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onSuccess: STABLE (function type) + +@Composable +private fun com.wire.android.ui.authentication.create.username.UsernameContent(textState: androidx.compose.foundation.text.input.TextFieldState, state: com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewState, onContinuePressed: kotlin.Function0, onErrorDismiss: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - textState: STABLE (marked @Stable or @Immutable) + - state: STABLE (class with no mutable properties) - onContinuePressed: STABLE (function type) - - overviewParams: STABLE (class with no mutable properties) - - serverConfig: UNSTABLE (has mutable properties or unstable members) + - onErrorDismiss: STABLE (function type) @Composable -private fun com.wire.android.ui.authentication.create.overview.OverviewTexts(overviewParams: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewParams, modifier: androidx.compose.ui.Modifier, onLearnMoreClick: kotlin.Function0): kotlin.Unit +public fun com.wire.android.ui.authentication.createAccountCodeViewModel(): com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel + skippable: true + restartable: true + params: + +@Composable +public fun com.wire.android.ui.authentication.createAccountCodeViewModel(navArgs: com.wire.android.ui.authentication.create.common.CreateAccountNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel + skippable: false + restartable: true + params: + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) + +@Composable +public fun com.wire.android.ui.authentication.createAccountDataDetailViewModel(): com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel + skippable: true + restartable: true + params: + +@Composable +public fun com.wire.android.ui.authentication.createAccountDataDetailViewModel(navArgs: com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel + skippable: false + restartable: true + params: + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) + +@Composable +public fun com.wire.android.ui.authentication.createAccountDetailsViewModel(): com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel skippable: true restartable: true params: - - overviewParams: STABLE (class with no mutable properties) - - modifier: STABLE (marked @Stable or @Immutable) - - onLearnMoreClick: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.create.summary.CreateAccountSummaryScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel): kotlin.Unit +public fun com.wire.android.ui.authentication.createAccountDetailsViewModel(navArgs: com.wire.android.ui.authentication.create.common.CreateAccountNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - viewModel: UNSTABLE (has mutable properties or unstable members) + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -private fun com.wire.android.ui.authentication.create.summary.SummaryContent(state: com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewState, onContinuePressed: kotlin.Function0): kotlin.Unit +public fun com.wire.android.ui.authentication.createAccountEmailViewModel(): com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel skippable: true restartable: true params: - - state: STABLE (class with no mutable properties) - - onContinuePressed: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.create.username.CreateAccountUsernameScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel): kotlin.Unit +public fun com.wire.android.ui.authentication.createAccountEmailViewModel(navArgs: com.wire.android.ui.authentication.create.common.CreateAccountNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - viewModel: UNSTABLE (has mutable properties or unstable members) + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -private fun com.wire.android.ui.authentication.create.username.UsernameContent(textState: androidx.compose.foundation.text.input.TextFieldState, state: com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewState, onContinuePressed: kotlin.Function0, onErrorDismiss: kotlin.Function0): kotlin.Unit +public fun com.wire.android.ui.authentication.createAccountOverviewViewModel(): com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel skippable: true restartable: true params: - - textState: STABLE (marked @Stable or @Immutable) - - state: STABLE (class with no mutable properties) - - onContinuePressed: STABLE (function type) - - onErrorDismiss: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.createAccountCodeViewModel(): com.wire.android.ui.authentication.create.code.CreateAccountCodeViewModel - skippable: true +public fun com.wire.android.ui.authentication.createAccountOverviewViewModel(navArgs: com.wire.android.ui.authentication.create.overview.CreateAccountOverviewNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel + skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.authentication.createAccountDataDetailViewModel(): com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel +public fun com.wire.android.ui.authentication.createAccountSelectorViewModel(): com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel skippable: true restartable: true params: @Composable -public fun com.wire.android.ui.authentication.createAccountDetailsViewModel(): com.wire.android.ui.authentication.create.details.CreateAccountDetailsViewModel - skippable: true +public fun com.wire.android.ui.authentication.createAccountSelectorViewModel(navArgs: com.wire.android.ui.registration.selector.CreateAccountSelectorNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel + skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.authentication.createAccountEmailViewModel(): com.wire.android.ui.authentication.create.email.CreateAccountEmailViewModel +public fun com.wire.android.ui.authentication.createAccountSummaryViewModel(): com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel skippable: true restartable: true params: @Composable -public fun com.wire.android.ui.authentication.createAccountOverviewViewModel(): com.wire.android.ui.authentication.create.overview.CreateAccountOverviewViewModel - skippable: true +public fun com.wire.android.ui.authentication.createAccountSummaryViewModel(navArgs: com.wire.android.ui.authentication.create.summary.CreateAccountSummaryNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel + skippable: false restartable: true params: + - navArgs: STABLE (class with no mutable properties) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.authentication.createAccountSelectorViewModel(): com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel +public fun com.wire.android.ui.authentication.createAccountUsernameViewModel(): com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel skippable: true restartable: true params: @Composable -public fun com.wire.android.ui.authentication.createAccountSummaryViewModel(): com.wire.android.ui.authentication.create.summary.CreateAccountSummaryViewModel - skippable: true +public fun com.wire.android.ui.authentication.createAccountUsernameViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel + skippable: false restartable: true params: + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.authentication.createAccountUsernameViewModel(): com.wire.android.ui.authentication.create.username.CreateAccountUsernameViewModel +public fun com.wire.android.ui.authentication.createAccountVerificationCodeViewModel(): com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel skippable: true restartable: true params: @Composable -public fun com.wire.android.ui.authentication.createAccountVerificationCodeViewModel(): com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel - skippable: true +public fun com.wire.android.ui.authentication.createAccountVerificationCodeViewModel(navArgs: com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel + skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable public fun com.wire.android.ui.authentication.devices.DeviceItem(device: com.wire.android.ui.authentication.devices.model.Device, placeholder: kotlin.Boolean, shouldShowVerifyLabel: kotlin.Boolean, icon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, modifier: androidx.compose.ui.Modifier, isCurrentClient: kotlin.Boolean, shouldShowE2EIInfo: kotlin.Boolean, isWholeItemClickable: kotlin.Boolean, onClickAction: kotlin.Function1?): kotlin.Unit @@ -1254,15 +910,17 @@ private fun com.wire.android.ui.authentication.devices.register.RegisterDeviceCo - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.authentication.devices.register.RegisterDeviceScreen(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, sessionBackedAuthenticationNavArgs: com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs, viewModel: com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel, clearSessionViewModel: com.wire.android.ui.authentication.devices.common.ClearSessionViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.devices.register.RegisterDeviceRouteScreen(viewModel: com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel, clearSessionViewModel: com.wire.android.ui.authentication.devices.common.ClearSessionViewModel, switchAccountActions: com.wire.android.feature.SwitchAccountActions, onE2EIRequired: kotlin.Function1, onHomeRequired: kotlin.Function0, onInitialSyncRequired: kotlin.Function0, onRemoveDeviceRequired: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) - - sessionBackedAuthenticationNavArgs: UNSTABLE (has mutable properties or unstable members) - viewModel: UNSTABLE (has mutable properties or unstable members) - clearSessionViewModel: UNSTABLE (has mutable properties or unstable members) + - switchAccountActions: RUNTIME (requires runtime check) + - onE2EIRequired: STABLE (function type) + - onHomeRequired: STABLE (function type) + - onInitialSyncRequired: STABLE (function type) + - onRemoveDeviceRequired: STABLE (function type) @Composable public fun com.wire.android.ui.authentication.devices.register.RegisterDeviceVerificationCodeScreen(viewModel: com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel): kotlin.Unit @@ -1311,15 +969,16 @@ private fun com.wire.android.ui.authentication.devices.remove.RemoveDeviceItemsL - onItemClicked: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.devices.remove.RemoveDeviceScreen(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, sessionBackedAuthenticationNavArgs: com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs, viewModel: com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel, clearSessionViewModel: com.wire.android.ui.authentication.devices.common.ClearSessionViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.devices.remove.RemoveDeviceRouteScreen(viewModel: com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel, clearSessionViewModel: com.wire.android.ui.authentication.devices.common.ClearSessionViewModel, switchAccountActions: com.wire.android.feature.SwitchAccountActions, onE2EIRequired: kotlin.Function0, onHomeRequired: kotlin.Function0, onInitialSyncRequired: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) - - sessionBackedAuthenticationNavArgs: UNSTABLE (has mutable properties or unstable members) - viewModel: UNSTABLE (has mutable properties or unstable members) - clearSessionViewModel: UNSTABLE (has mutable properties or unstable members) + - switchAccountActions: RUNTIME (requires runtime check) + - onE2EIRequired: STABLE (function type) + - onHomeRequired: STABLE (function type) + - onInitialSyncRequired: STABLE (function type) @Composable public fun com.wire.android.ui.authentication.devices.remove.RemoveDeviceTopBar(elevation: androidx.compose.ui.unit.Dp, onBackButtonClicked: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -1359,13 +1018,15 @@ public fun com.wire.android.ui.authentication.login.LoginErrorDialog(dialogError - onDialogDismiss: STABLE (function type) @Composable -public fun com.wire.android.ui.authentication.login.LoginScreen(navigator: com.wire.android.navigation.Navigator, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.login.LoginRouteScreen(loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, onBackPressed: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - loginNavArgs: UNSTABLE (has mutable properties or unstable members) - loginEmailViewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onSuccess: STABLE (function type) + - onRemoveDeviceNeeded: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.login.MainLoginContent(onBackPressed: kotlin.Function0, onSuccess: kotlin.Function3<@[ParameterName(name = \, onRemoveDeviceNeeded: kotlin.Function1, loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, ssoLoginResult: com.wire.android.util.deeplink.DeepLinkResult.SSOLogin?, ssoCodeAutoLogin: com.wire.android.ui.authentication.login.SSOCodeAutoLogin?): kotlin.Unit @@ -1600,16 +1261,26 @@ public fun com.wire.android.ui.authentication.newLoginViewModel(): com.wire.andr params: @Composable -public fun com.wire.android.ui.authentication.registerDeviceViewModel(): com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel - skippable: true +public fun com.wire.android.ui.authentication.newLoginViewModel(loginNavArgs: com.wire.android.ui.authentication.login.LoginNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.newauthentication.login.NewLoginViewModel + skippable: false restartable: true params: + - loginNavArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.authentication.removeDeviceViewModel(): com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel - skippable: true +public fun com.wire.android.ui.authentication.registerDeviceViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.devices.register.RegisterDeviceViewModel + skippable: false + restartable: true + params: + - viewModelStoreOwner: RUNTIME (requires runtime check) + +@Composable +public fun com.wire.android.ui.authentication.removeDeviceViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.devices.remove.RemoveDeviceViewModel + skippable: false restartable: true params: + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable private fun com.wire.android.ui.authentication.verificationcode.MainContent(codeTextState: androidx.compose.foundation.text.input.TextFieldState, codeState: com.wire.android.ui.authentication.verificationcode.VerificationCodeState, isLoading: kotlin.Boolean, onResendCode: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -1673,12 +1344,12 @@ private fun com.wire.android.ui.authentication.welcome.LoginButton(onClick: kotl - onClick: STABLE (function type) @Composable -private fun com.wire.android.ui.authentication.welcome.WelcomeButtonsColumn(state: com.wire.kalium.logic.configuration.server.ServerConfig.Links, navigate: kotlin.Function1): kotlin.Unit +private fun com.wire.android.ui.authentication.welcome.WelcomeButtonsColumn(state: com.wire.kalium.logic.configuration.server.ServerConfig.Links, onAction: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - state: UNSTABLE (has mutable properties or unstable members) - - navigate: STABLE (function type) + - onAction: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.welcome.WelcomeCarousel(modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -1696,7 +1367,7 @@ private fun com.wire.android.ui.authentication.welcome.WelcomeCarouselItem(pageI - pageText: STABLE (String is immutable) @Composable -private fun com.wire.android.ui.authentication.welcome.WelcomeContent(isThereActiveSession: kotlin.Boolean, maxAccountsReached: kotlin.Boolean, nomadAccountBlocksLogin: kotlin.Boolean, state: com.wire.kalium.logic.configuration.server.ServerConfig.Links, navigateBack: kotlin.Function0, navigate: kotlin.Function1): kotlin.Unit +private fun com.wire.android.ui.authentication.welcome.WelcomeContent(isThereActiveSession: kotlin.Boolean, maxAccountsReached: kotlin.Boolean, nomadAccountBlocksLogin: kotlin.Boolean, state: com.wire.kalium.logic.configuration.server.ServerConfig.Links, navigateBack: kotlin.Function0, onAction: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: @@ -1705,7 +1376,7 @@ private fun com.wire.android.ui.authentication.welcome.WelcomeContent(isThereAct - nomadAccountBlocksLogin: STABLE (primitive type) - state: UNSTABLE (has mutable properties or unstable members) - navigateBack: STABLE (function type) - - navigate: STABLE (function type) + - onAction: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.welcome.WelcomeFooter(onPrivateAccountClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -1716,21 +1387,22 @@ private fun com.wire.android.ui.authentication.welcome.WelcomeFooter(onPrivateAc - modifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.wire.android.ui.authentication.welcome.WelcomeFooterSection(state: com.wire.kalium.logic.configuration.server.ServerConfig.Links, navigate: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit +private fun com.wire.android.ui.authentication.welcome.WelcomeFooterSection(state: com.wire.kalium.logic.configuration.server.ServerConfig.Links, onAction: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - state: UNSTABLE (has mutable properties or unstable members) - - navigate: STABLE (function type) + - onAction: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.authentication.welcome.WelcomeScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.authentication.welcome.WelcomeViewModel): kotlin.Unit +internal fun com.wire.android.ui.authentication.welcome.WelcomeRouteScreen(viewModel: com.wire.android.ui.authentication.welcome.WelcomeViewModel, onNavigateBack: kotlin.Function0, onAction: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onAction: STABLE (function type) @Composable private fun com.wire.android.ui.authentication.welcome.typedArrayResource(id: kotlin.Int): android.content.res.TypedArray @@ -1746,16 +1418,33 @@ public fun com.wire.android.ui.authentication.welcomeViewModel(): com.wire.andro params: @Composable -public fun com.wire.android.ui.calling.CallActivity.Content(): kotlin.Unit - skippable: true +public fun com.wire.android.ui.authentication.welcomeViewModel(navArgs: com.wire.android.ui.authentication.welcome.WelcomeNavArgs, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.authentication.welcome.WelcomeViewModel + skippable: false restartable: true params: + - navArgs: UNSTABLE (has mutable properties or unstable members) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.calling.StartingCallActivity.Content(): kotlin.Unit - skippable: true +private fun com.wire.android.ui.calling.CallActivity.CallActivityRoot(request: com.wire.android.ui.calling.CallActivityRequest): kotlin.Unit + skippable: false + restartable: true + params: + - request: UNSTABLE (has mutable properties or unstable members) + +@Composable +public fun com.wire.android.ui.calling.CallActivity.Content(request: com.wire.android.ui.calling.CallActivityRequest): kotlin.Unit + skippable: false + restartable: true + params: + - request: UNSTABLE (has mutable properties or unstable members) + +@Composable +public fun com.wire.android.ui.calling.StartingCallActivity.Content(request: com.wire.android.ui.calling.CallActivityRequest): kotlin.Unit + skippable: false restartable: true params: + - request: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.calling.common.CallVideoPreview(isCameraOn: kotlin.Boolean, onVideoPreviewCreated: kotlin.Function1<@[ParameterName(name = \, onSelfClearVideoPreview: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -1946,6 +1635,13 @@ public fun com.wire.android.ui.calling.conversationCallViewModel(): com.wire.and restartable: true params: +@Composable +public fun com.wire.android.ui.calling.conversationCallViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.call.ConversationCallViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.calling.conversationListCallViewModel(conversationsSource: com.wire.android.ui.home.conversationslist.model.ConversationsSource): com.wire.android.ui.home.conversationslist.ConversationListCallViewModel skippable: true @@ -2040,10 +1736,11 @@ private fun com.wire.android.ui.calling.ongoing.HandleSendingVideoFeed(callState - clearVideoPreview: STABLE (function type) @Composable -public fun com.wire.android.ui.calling.ongoing.OngoingCallActivity.Content(): kotlin.Unit - skippable: true +public fun com.wire.android.ui.calling.ongoing.OngoingCallActivity.Content(request: com.wire.android.ui.calling.CallActivityRequest): kotlin.Unit + skippable: false restartable: true params: + - request: UNSTABLE (has mutable properties or unstable members) @Composable private fun com.wire.android.ui.calling.ongoing.OngoingCallContent(callState: com.wire.android.ui.calling.model.CallState, inCallReactionsState: com.wire.android.ui.calling.ongoing.incallreactions.InCallReactionsState, scaffoldState: androidx.compose.material3.BottomSheetScaffoldState, toggleSpeaker: kotlin.Function0, toggleMute: kotlin.Function0, hangUpCall: kotlin.Function0, toggleVideo: kotlin.Function0, flipCamera: kotlin.Function0, setVideoPreview: kotlin.Function1<@[ParameterName(name = \, clearVideoPreview: kotlin.Function0, onCollapse: kotlin.Function0, onOpenCallDetails: kotlin.Function0, onCameraPermissionPermanentlyDenied: kotlin.Function0, onReactionClick: kotlin.Function1, requestVideoStreams: kotlin.Function1<@[ParameterName(name = \, onSelectedParticipant: kotlin.Function1<@[ParameterName(name = \, selectedParticipantForFullScreen: com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant?, participants: kotlinx.collections.immutable.PersistentList, recentReactions: kotlin.collections.Map, callQuality: com.wire.android.ui.calling.ongoing.details.CallQualityState.Quality, othersVideosDisabled: kotlin.Boolean, toasts: kotlin.collections.Set, onToastClick: kotlin.Function1<@[ParameterName(name = \, inCallReactionsEnabled: kotlin.Boolean, initialShowInCallReactionsPanel: kotlin.Boolean): kotlin.Unit @@ -3048,6 +2745,14 @@ private fun com.wire.android.ui.common.bottomsheet.conversation.PreviewConversat params: - initialPage: STABLE (class with no mutable properties) +@Composable +public fun com.wire.android.ui.common.commonTopAppBarViewModel(params: com.wire.android.ui.common.topappbar.CommonTopAppBarParams, owner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel + skippable: false + restartable: true + params: + - params: STABLE (class with no mutable properties) + - owner: RUNTIME (requires runtime check) + @Composable public fun com.wire.android.ui.common.conversationOptionsMenuViewModel(): com.wire.android.ui.common.bottomsheet.conversation.ConversationOptionsMenuViewModel skippable: true @@ -3586,11 +3291,14 @@ public fun com.wire.android.ui.debug.DebugDataOptionsContent(state: com.wire.and - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.debug.DebugScreen(navigator: com.wire.android.navigation.Navigator, userDebugViewModel: com.wire.android.ui.debug.UserDebugViewModel, debugDataOptionsViewModel: com.wire.android.ui.debug.DebugDataOptionsViewModel, exportObfuscatedCopyViewModel: com.wire.android.ui.debug.ExportObfuscatedCopyViewModel): kotlin.Unit +internal fun com.wire.android.ui.debug.DebugRouteScreen(onBack: kotlin.Function0, onShowFeatureFlags: kotlin.Function0, onShowCryptoStats: kotlin.Function0, onShareLogsViaWire: kotlin.Function1, userDebugViewModel: com.wire.android.ui.debug.UserDebugViewModel, debugDataOptionsViewModel: com.wire.android.ui.debug.DebugDataOptionsViewModel, exportObfuscatedCopyViewModel: com.wire.android.ui.debug.ExportObfuscatedCopyViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) + - onShowFeatureFlags: STABLE (function type) + - onShowCryptoStats: STABLE (function type) + - onShareLogsViaWire: STABLE (function type) - userDebugViewModel: UNSTABLE (has mutable properties or unstable members) - debugDataOptionsViewModel: RUNTIME (requires runtime check) - exportObfuscatedCopyViewModel: RUNTIME (requires runtime check) @@ -3650,11 +3358,12 @@ private fun com.wire.android.ui.debug.ForceUpdateApiVersionsButton(onClick: kotl - onClick: STABLE (function type) @Composable -public fun com.wire.android.ui.debug.LogManagementScreen(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.LogManagementViewModel): kotlin.Unit +internal fun com.wire.android.ui.debug.LogManagementRouteScreen(onBack: kotlin.Function0, onShareLogsViaWire: kotlin.Function1, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.LogManagementViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) + - onShareLogsViaWire: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -3790,13 +3499,13 @@ private fun com.wire.android.ui.debug.conversation.ConversationFeedConfigView(co - onRunFeeders: STABLE (function type) @Composable -public fun com.wire.android.ui.debug.conversation.DebugConversationScreen(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.conversation.DebugConversationViewModel): kotlin.Unit +internal fun com.wire.android.ui.debug.conversation.DebugConversationRouteScreen(viewModel: com.wire.android.ui.debug.conversation.DebugConversationViewModel, onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.ui.debug.conversation.MlsDetailsView(state: com.wire.android.ui.debug.conversation.DebugConversationViewState, mlsProtocolInfo: com.wire.kalium.logic.data.conversation.Conversation.ProtocolInfo.MLSCapable, onRefreshCcEpoch: kotlin.Function0): kotlin.Unit @@ -3814,11 +3523,11 @@ public fun com.wire.android.ui.debug.conversationCryptoStatsViewModel(): com.wir params: @Composable -public fun com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsScreen(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsViewModel): kotlin.Unit +internal fun com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsRouteScreen(onBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.cryptostats.ConversationCryptoStatsViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -3854,10 +3563,11 @@ private fun com.wire.android.ui.debug.cryptostats.StatsSummary(uiModel: com.wire - uiModel: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.debug.debugConversationViewModel(): com.wire.android.ui.debug.conversation.DebugConversationViewModel - skippable: true +public fun com.wire.android.ui.debug.debugConversationViewModel(args: com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs): com.wire.android.ui.debug.conversation.DebugConversationViewModel + skippable: false restartable: true params: + - args: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.debug.debugDataOptionsViewModel(): com.wire.android.ui.debug.DebugDataOptionsViewModel @@ -3892,11 +3602,11 @@ public fun com.wire.android.ui.debug.exportObfuscatedCopyViewModel(): com.wire.a params: @Composable -public fun com.wire.android.ui.debug.featureflags.DebugFeatureFlagsScreen(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.featureflags.DebugFeatureFlagsViewModel): kotlin.Unit +internal fun com.wire.android.ui.debug.featureflags.DebugFeatureFlagsRouteScreen(onBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.featureflags.DebugFeatureFlagsViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @@ -3940,27 +3650,22 @@ public fun com.wire.android.ui.debug.whatsNewViewModel(): com.wire.android.ui.ho params: @Composable -public fun com.wire.android.ui.e2EIEnrollmentViewModel(): com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentViewModel - skippable: true - restartable: true - params: - -@Composable -public fun com.wire.android.ui.e2eiCertificateDetailsViewModel(): com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel - skippable: true +public fun com.wire.android.ui.e2EIEnrollmentViewModel(owner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentViewModel + skippable: false restartable: true params: + - owner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentScreen(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, sessionBackedAuthenticationNavArgs: com.wire.android.ui.authentication.devices.common.SessionBackedAuthenticationNavArgs, viewModel: com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentViewModel, clearSessionViewModel: com.wire.android.ui.authentication.devices.common.ClearSessionViewModel): kotlin.Unit +internal fun com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRouteScreen(viewModel: com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentViewModel, clearSessionViewModel: com.wire.android.ui.authentication.devices.common.ClearSessionViewModel, switchAccountActions: com.wire.android.feature.SwitchAccountActions, onInitialSyncRequired: kotlin.Function0, onOpenCertificateDetails: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) - - sessionBackedAuthenticationNavArgs: UNSTABLE (has mutable properties or unstable members) - viewModel: UNSTABLE (has mutable properties or unstable members) - clearSessionViewModel: UNSTABLE (has mutable properties or unstable members) + - switchAccountActions: RUNTIME (requires runtime check) + - onInitialSyncRequired: STABLE (function type) + - onOpenCertificateDetails: STABLE (function type) @Composable private fun com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentScreenContent(state: com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentState, clearSessionState: com.wire.android.ui.authentication.devices.common.ClearSessionState, dismissSuccess: kotlin.Function0, dismissErrorDialog: kotlin.Function0, enrollE2EICertificate: kotlin.Function0, handleE2EIEnrollmentResult: kotlin.Function1, openCertificateDetails: kotlin.Function0, onBackButtonClicked: kotlin.Function0, onCancelEnrollmentClicked: kotlin.Function0, onProceedEnrollmentClicked: kotlin.Function0): kotlin.Unit @@ -4084,10 +3789,11 @@ public fun com.wire.android.ui.emoji.EmojiPickerBottomSheet(sheetState: com.wire - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.getE2EICertificateViewModel(): com.wire.android.ui.e2eiEnrollment.GetE2EICertificateViewModel - skippable: true +public fun com.wire.android.ui.getE2EICertificateViewModel(owner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.e2eiEnrollment.GetE2EICertificateViewModel + skippable: false restartable: true params: + - owner: RUNTIME (requires runtime check) @Composable public fun com.wire.android.ui.home.AnalyticsUsageDialog(agreeOption: kotlin.Function0, declineOption: kotlin.Function0, context: android.content.Context): kotlin.Unit @@ -4237,23 +3943,25 @@ public fun com.wire.android.ui.home.E2EIUpdateErrorWithDismissDialog(isE2EILoadi - onDismiss: STABLE (function type) @Composable -public fun com.wire.android.ui.home.HomeContent(homeState: com.wire.android.ui.home.HomeState, homeDrawerState: com.wire.android.ui.home.drawer.HomeDrawerState, homeStateHolder: com.wire.android.ui.home.HomeStateHolder, onNewConversationClick: kotlin.Function0, onSelfUserClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.ui.home.HomeContent(homeState: com.wire.android.ui.home.HomeState, homeDrawerState: com.wire.android.ui.home.drawer.HomeDrawerState, homeStateHolder: com.wire.android.ui.home.HomeShellState, onNewConversationClick: kotlin.Function0, onSelfUserClick: kotlin.Function0, onNavigateToHomeItem: kotlin.Function1, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - homeState: STABLE (class with no mutable properties) - homeDrawerState: STABLE (class with no mutable properties) - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - homeStateHolder: RUNTIME (requires runtime check) - onNewConversationClick: STABLE (function type) - onSelfUserClick: STABLE (function type) + - onNavigateToHomeItem: STABLE (function type) + - content: STABLE (composable function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.wire.android.ui.home.HomeDrawerSheet(currentRoute: kotlin.String, homeDrawerState: com.wire.android.ui.home.drawer.HomeDrawerState, focusTrapState: com.wire.android.ui.home.HomeDrawerSheetFocusTrapState, focusTrapActions: com.wire.android.ui.home.HomeDrawerSheetFocusTrapActions, firstItemFocusRequester: androidx.compose.ui.focus.FocusRequester, lastItemFocusRequester: androidx.compose.ui.focus.FocusRequester, onNavigateToHomeItem: kotlin.Function1, onCloseDrawer: kotlin.Function0): kotlin.Unit +internal fun com.wire.android.ui.home.HomeDrawerSheet(currentDestination: com.wire.android.navigation.HomeDestination, homeDrawerState: com.wire.android.ui.home.drawer.HomeDrawerState, focusTrapState: com.wire.android.ui.home.HomeDrawerSheetFocusTrapState, focusTrapActions: com.wire.android.ui.home.HomeDrawerSheetFocusTrapActions, firstItemFocusRequester: androidx.compose.ui.focus.FocusRequester, lastItemFocusRequester: androidx.compose.ui.focus.FocusRequester, onNavigateToHomeItem: kotlin.Function1, onCloseDrawer: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: - - currentRoute: STABLE (String is immutable) + - currentDestination: STABLE (class with no mutable properties) - homeDrawerState: STABLE (class with no mutable properties) - focusTrapState: STABLE (class with no mutable properties) - focusTrapActions: STABLE (class with no mutable properties) @@ -4264,24 +3972,42 @@ internal fun com.wire.android.ui.home.HomeDrawerSheet(currentRoute: kotlin.Strin @Composable private fun com.wire.android.ui.home.HomeFloatingActionButton(currentNavigationItem: com.wire.android.navigation.HomeDestination, fabFocusRequester: androidx.compose.ui.focus.FocusRequester, nextFocusRequester: androidx.compose.ui.focus.FocusRequester, onNewConversationClick: kotlin.Function0, onNewMeetingClick: kotlin.Function0): kotlin.Unit - skippable: false + skippable: true restartable: true params: - - currentNavigationItem: RUNTIME (requires runtime check) + - currentNavigationItem: STABLE (class with no mutable properties) - fabFocusRequester: STABLE (marked @Stable or @Immutable) - nextFocusRequester: STABLE (marked @Stable or @Immutable) - onNewConversationClick: STABLE (function type) - onNewMeetingClick: STABLE (function type) @Composable -private fun com.wire.android.ui.home.HomeNavHost(state: com.wire.android.ui.home.HomeScaffoldState): kotlin.Unit - skippable: true +private fun com.wire.android.ui.home.HomeNavigation3Entry(route: com.wire.android.navigation.runtime.startup.HomeRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.HomeNavigation3Actions, homeViewModel: com.wire.android.ui.home.HomeViewModel, appSyncViewModel: com.wire.android.ui.home.AppSyncViewModel, homeDrawerViewModel: com.wire.android.ui.home.drawer.HomeDrawerViewModel, analyticsUsageViewModel: com.wire.android.ui.analytics.AnalyticsUsageViewModel): kotlin.Unit + skippable: false restartable: true params: - - state: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + - homeViewModel: UNSTABLE (has mutable properties or unstable members) + - appSyncViewModel: UNSTABLE (has mutable properties or unstable members) + - homeDrawerViewModel: UNSTABLE (has mutable properties or unstable members) + - analyticsUsageViewModel: UNSTABLE (has mutable properties or unstable members) + +@Composable +internal fun com.wire.android.ui.home.HomeNavigation3TopLevelContent(destination: com.wire.android.ui.home.HomeTopLevelDestination, shellState: com.wire.android.ui.home.HomeShellState, sessionId: com.wire.navigation.WireSessionId, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.HomeTopLevelNavigation3Actions, conversationsNavigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions): kotlin.Unit + skippable: false + restartable: true + params: + - destination: STABLE (class with no mutable properties) + - shellState: RUNTIME (requires runtime check) + - sessionId: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + - conversationsNavigationActions: STABLE (class with no mutable properties) @Composable -internal fun com.wire.android.ui.home.HomeScaffold(homeState: com.wire.android.ui.home.HomeState, state: com.wire.android.ui.home.HomeScaffoldState, drawerState: androidx.compose.material3.DrawerState, focusRequesters: com.wire.android.ui.home.HomeScaffoldFocusRequesters, actions: com.wire.android.ui.home.HomeScaffoldActions): kotlin.Unit +internal fun com.wire.android.ui.home.HomeScaffold(homeState: com.wire.android.ui.home.HomeState, state: com.wire.android.ui.home.HomeScaffoldState, drawerState: androidx.compose.material3.DrawerState, focusRequesters: com.wire.android.ui.home.HomeScaffoldFocusRequesters, actions: com.wire.android.ui.home.HomeScaffoldActions, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit skippable: true restartable: true params: @@ -4290,6 +4016,7 @@ internal fun com.wire.android.ui.home.HomeScaffold(homeState: com.wire.android.u - drawerState: STABLE (marked @Stable or @Immutable) - focusRequesters: STABLE (class with no mutable properties) - actions: STABLE (class with no mutable properties) + - content: STABLE (composable function type) @Composable private fun com.wire.android.ui.home.HomeScaffoldFloatingActionButton(state: com.wire.android.ui.home.HomeScaffoldState, fabFocusRequester: androidx.compose.ui.focus.FocusRequester, onNewConversationClick: kotlin.Function0): kotlin.Unit @@ -4300,20 +4027,6 @@ private fun com.wire.android.ui.home.HomeScaffoldFloatingActionButton(state: com - fabFocusRequester: STABLE (marked @Stable or @Immutable) - onNewConversationClick: STABLE (function type) -@Composable -public fun com.wire.android.ui.home.HomeScreen(navigator: com.wire.android.navigation.Navigator, groupDetailsScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, otherUserProfileScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, conversationFoldersScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, homeViewModel: com.wire.android.ui.home.HomeViewModel, appSyncViewModel: com.wire.android.ui.home.AppSyncViewModel, homeDrawerViewModel: com.wire.android.ui.home.drawer.HomeDrawerViewModel, analyticsUsageViewModel: com.wire.android.ui.analytics.AnalyticsUsageViewModel): kotlin.Unit - skippable: false - restartable: true - params: - - navigator: STABLE (marked @Stable or @Immutable) - - groupDetailsScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - otherUserProfileScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - conversationFoldersScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - homeViewModel: UNSTABLE (has mutable properties or unstable members) - - appSyncViewModel: UNSTABLE (has mutable properties or unstable members) - - homeDrawerViewModel: UNSTABLE (has mutable properties or unstable members) - - analyticsUsageViewModel: UNSTABLE (has mutable properties or unstable members) - @Composable private fun com.wire.android.ui.home.HomeSearchTopBar(state: com.wire.android.ui.home.HomeScaffoldState, searchFocusRequester: androidx.compose.ui.focus.FocusRequester, fabFocusRequester: androidx.compose.ui.focus.FocusRequester): kotlin.Unit skippable: true @@ -4330,7 +4043,7 @@ public fun com.wire.android.ui.home.HomeTopBar(title: kotlin.String, currentConv params: - title: STABLE (String is immutable) - currentConversationFilter: UNSTABLE (has mutable properties or unstable members) - - navigationItem: RUNTIME (requires runtime check) + - navigationItem: STABLE (class with no mutable properties) - userAvatarData: STABLE (marked @Stable or @Immutable) - elevation: STABLE (marked @Stable or @Immutable) - withLegalHoldIndicator: STABLE (primitive type) @@ -4353,10 +4066,19 @@ private fun com.wire.android.ui.home.HomeTopBarHeader(homeState: com.wire.androi - onHamburgerMenuClick: STABLE (function type) @Composable -public fun com.wire.android.ui.home.appLock.forgot.ForgotLockCodeScreen(viewModel: com.wire.android.ui.home.appLock.forgot.ForgotLockScreenViewModel): kotlin.Unit +private fun com.wire.android.ui.home.Navigation3GlobalCells(navigationActions: com.wire.android.feature.cells.ui.AllFilesNavigationActions, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit + skippable: false + restartable: true + params: + - navigationActions: STABLE (class with no mutable properties) + - viewModel: UNSTABLE (has mutable properties or unstable members) + +@Composable +internal fun com.wire.android.ui.home.appLock.forgot.ForgotLockCodeRouteScreen(onLogoutCompleted: kotlin.Function0, viewModel: com.wire.android.ui.home.appLock.forgot.ForgotLockScreenViewModel): kotlin.Unit skippable: false restartable: true params: + - onLogoutCompleted: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -4403,23 +4125,23 @@ private fun com.wire.android.ui.home.appLock.set.PasswordVerificationItem(isInva - text: STABLE (String is immutable) @Composable -public fun com.wire.android.ui.home.appLock.set.SetLockCodeScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.appLock.set.SetLockScreenViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.appLock.set.SetLockCodeRouteScreen(onBack: kotlin.Function0, viewModel: com.wire.android.ui.home.appLock.set.SetLockScreenViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.home.appLock.set.SetLockCodeScreenContent(navigator: com.wire.android.navigation.Navigator, state: com.wire.android.ui.home.appLock.set.SetLockCodeViewState, passwordTextState: androidx.compose.foundation.text.input.TextFieldState, scrollState: androidx.compose.foundation.ScrollState, onBackPress: kotlin.Function0, onContinue: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.ui.home.appLock.set.SetLockCodeScreenContent(state: com.wire.android.ui.home.appLock.set.SetLockCodeViewState, passwordTextState: androidx.compose.foundation.text.input.TextFieldState, scrollState: androidx.compose.foundation.ScrollState, onBackPress: kotlin.Function0, onDone: kotlin.Function0, onContinue: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - state: UNSTABLE (has mutable properties or unstable members) - passwordTextState: STABLE (marked @Stable or @Immutable) - scrollState: STABLE (marked @Stable or @Immutable) - onBackPress: STABLE (function type) + - onDone: STABLE (function type) - onContinue: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @@ -4430,12 +4152,13 @@ private fun com.wire.android.ui.home.appLock.unlock.AppUnLockBackground(): kotli params: @Composable -public fun com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsScreen(navigator: com.wire.android.navigation.Navigator, appUnlockWithBiometricsViewModel: com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsViewModel): kotlin.Unit - skippable: false +internal fun com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsRouteScreen(onUnlocked: kotlin.Function0, onCancel: kotlin.Function0, onRequestPasscode: kotlin.Function0): kotlin.Unit + skippable: true restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - appUnlockWithBiometricsViewModel: UNSTABLE (has mutable properties or unstable members) + - onUnlocked: STABLE (function type) + - onCancel: STABLE (function type) + - onRequestPasscode: STABLE (function type) @Composable private fun com.wire.android.ui.home.appLock.unlock.ContinueButton(enabled: kotlin.Boolean, onContinue: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -4447,11 +4170,14 @@ private fun com.wire.android.ui.home.appLock.unlock.ContinueButton(enabled: kotl - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.appLock.unlock.EnterLockCodeScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.appLock.unlock.EnterLockScreenViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.appLock.unlock.EnterLockCodeRouteScreen(onBack: kotlin.Function0, canNavigateBackToBiometric: kotlin.Boolean, onForgotCode: kotlin.Function0, onUnlockCompleted: kotlin.Function0, viewModel: com.wire.android.ui.home.appLock.unlock.EnterLockScreenViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBack: STABLE (function type) + - canNavigateBackToBiometric: STABLE (primitive type) + - onForgotCode: STABLE (function type) + - onUnlockCompleted: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -4480,18 +4206,19 @@ public fun com.wire.android.ui.home.archive.ArchiveEmptyContent(modifier: androi - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.archive.ArchiveScreen(homeStateHolder: com.wire.android.ui.home.HomeStateHolder): kotlin.Unit +internal fun com.wire.android.ui.home.archive.ArchiveScreen(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions): kotlin.Unit skippable: false restartable: true params: - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - homeShellState: RUNTIME (requires runtime check) + - navigationActions: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.home.cell.GlobalCellsScreen(homeStateHolder: com.wire.android.ui.home.HomeStateHolder, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.cell.GlobalCellsScreen(navigationActions: com.wire.android.feature.cells.ui.AllFilesNavigationActions, viewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit skippable: false restartable: true params: - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - navigationActions: STABLE (class with no mutable properties) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -4554,6 +4281,14 @@ internal fun com.wire.android.ui.home.conversations.ConversationDialogs(state: c - state: UNSTABLE (has mutable properties or unstable members) - actions: STABLE (class with no mutable properties) +@Composable +private fun com.wire.android.ui.home.conversations.ConversationFoldersNavigation3Entry(route: com.wire.android.ui.home.conversations.ConversationFoldersRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + @Composable internal fun com.wire.android.ui.home.conversations.ConversationMessageComposer(conversationId: com.wire.kalium.logic.data.id.QualifiedID, bottomSheetVisible: kotlin.Boolean, lastUnreadMessageInstant: kotlinx.datetime.Instant?, unreadEventCount: kotlin.Int, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, selectedMessageId: kotlin.String?, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, attachments: kotlin.collections.List, messages: kotlinx.coroutines.flow.Flow>, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onReactionClicked: kotlin.Function2, onResetSessionClicked: kotlin.Function2<@[ParameterName(name = \, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onShowEditingOptions: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, onFailedMessageRetryClicked: kotlin.Function2, onFailedMessageCancelClicked: kotlin.Function1, onChangeSelfDeletionClicked: kotlin.Function1, onClearMentionSearchResult: kotlin.Function0, onLocationClicked: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onLinkClick: kotlin.Function1, onNavigateToReplyOriginalMessage: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit skippable: false @@ -4607,35 +4342,21 @@ internal fun com.wire.android.ui.home.conversations.ConversationMessageComposer( - isWireCellsEnabled: STABLE (primitive type) @Composable -internal fun com.wire.android.ui.home.conversations.ConversationNavigationResults(groupDetailsScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, mediaGalleryScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, imagePreviewScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, drawingCanvasScreenResultRecipient: com.ramcosta.composedestinations.result.OpenResultRecipient, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, navigator: com.wire.android.navigation.Navigator, conversationId: com.wire.kalium.logic.data.id.QualifiedID, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, getAndResetLastFullscreenMessage: kotlin.Function1, toggleReaction: kotlin.Function2<@[ParameterName(name = \, trySendMessages: kotlin.Function1, kotlin.Unit>, trySendMessage: kotlin.Function1, onConversationDeleted: kotlin.Function0): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationNavigation3Entry(route: com.wire.android.ui.home.conversations.ConversationRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.conversations.ConversationNavigation3Actions): kotlin.Unit skippable: false restartable: true params: - - groupDetailsScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - mediaGalleryScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - imagePreviewScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - drawingCanvasScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - - navigator: STABLE (marked @Stable or @Immutable) - - conversationId: STABLE (matched by stability configuration) - - messageComposerStateHolder: UNSTABLE (has mutable properties or unstable members) - - getAndResetLastFullscreenMessage: STABLE (function type) - - toggleReaction: STABLE (function type) - - trySendMessages: STABLE (function type) - - trySendMessage: STABLE (function type) - - onConversationDeleted: STABLE (function type) + - route: RUNTIME (requires runtime check) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversations.ConversationScreen(navigator: com.wire.android.navigation.Navigator, groupDetailsScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, mediaGalleryScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, imagePreviewScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, drawingCanvasScreenResultRecipient: com.ramcosta.composedestinations.result.OpenResultRecipient, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, conversationInfoViewModel: com.wire.android.ui.home.conversations.info.ConversationInfoViewModel, conversationBannerViewModel: com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel, conversationCallViewModel: com.wire.android.ui.home.conversations.call.ConversationCallViewModel, conversationMessagesViewModel: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel, messageComposerViewModel: com.wire.android.ui.home.conversations.composer.MessageComposerViewModel, sendMessageViewModel: com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel, conversationMigrationViewModel: com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel, messageDraftViewModel: com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel, messageAttachmentsViewModel: com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.ConversationRouteScreen(navigation: com.wire.android.ui.home.conversations.ConversationRouteScreenNavigation, onShareAssetViaWire: kotlin.Function1, conversationInfoViewModel: com.wire.android.ui.home.conversations.info.ConversationInfoViewModel, conversationBannerViewModel: com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel, conversationCallViewModel: com.wire.android.ui.home.conversations.call.ConversationCallViewModel, conversationMessagesViewModel: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel, messageComposerViewModel: com.wire.android.ui.home.conversations.composer.MessageComposerViewModel, sendMessageViewModel: com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel, conversationMigrationViewModel: com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel, messageDraftViewModel: com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel, messageAttachmentsViewModel: com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - groupDetailsScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - mediaGalleryScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - imagePreviewScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - drawingCanvasScreenResultRecipient: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) + - navigation: RUNTIME (requires runtime check) + - onShareAssetViaWire: STABLE (function type) - conversationInfoViewModel: UNSTABLE (has mutable properties or unstable members) - conversationBannerViewModel: UNSTABLE (has mutable properties or unstable members) - conversationCallViewModel: UNSTABLE (has mutable properties or unstable members) @@ -4647,7 +4368,7 @@ public fun com.wire.android.ui.home.conversations.ConversationScreen(navigator: - messageAttachmentsViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.home.conversations.ConversationScreenContent(bannerMessage: com.wire.android.util.ui.UIText?, messageComposerViewState: com.wire.android.ui.home.conversations.MessageComposerViewState, conversationCallViewState: com.wire.android.ui.home.conversations.call.ConversationCallViewState, conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, conversationMessagesViewState: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState, attachments: kotlin.collections.List, bottomSheetVisible: kotlin.Boolean, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onMessageDetailsClick: kotlin.Function2<@[ParameterName(name = \, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onDeleteMessage: kotlin.Function2, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onStartCall: kotlin.Function0, onJoinCall: kotlin.Function0, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onResetSessionClick: kotlin.Function2<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onDropDownClick: kotlin.Function0, onBackButtonClick: kotlin.Function0, composerMessages: kotlinx.coroutines.flow.SharedFlow, conversationMessages: kotlinx.coroutines.flow.SharedFlow, shareAssetExternally: kotlin.Function2, onSelfDeletingMessageRead: kotlin.Function1, onNewSelfDeletingMessagesStatus: kotlin.Function1, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onFailedMessageRetryClicked: kotlin.Function2, onClearMentionSearchResult: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, conversationScreenState: com.wire.android.ui.home.conversations.ConversationScreenState, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, onLinkClick: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationScreenContent(bannerMessage: com.wire.android.util.ui.UIText?, messageComposerViewState: com.wire.android.ui.home.conversations.MessageComposerViewState, conversationCallViewState: com.wire.android.ui.home.conversations.call.ConversationCallViewState, conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, conversationMessagesViewState: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState, attachments: kotlin.collections.List, bottomSheetVisible: kotlin.Boolean, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onMessageDetailsClick: kotlin.Function2<@[ParameterName(name = \, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onDeleteMessage: kotlin.Function2, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onStartCall: kotlin.Function0, onJoinCall: kotlin.Function0, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onResetSessionClick: kotlin.Function2<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onDropDownClick: kotlin.Function0, onBackButtonClick: kotlin.Function0, composerMessages: kotlinx.coroutines.flow.SharedFlow, conversationMessages: kotlinx.coroutines.flow.SharedFlow, shareAsset: kotlin.Function2, onSelfDeletingMessageRead: kotlin.Function1, onNewSelfDeletingMessagesStatus: kotlin.Function1, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onFailedMessageRetryClicked: kotlin.Function2, onClearMentionSearchResult: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, conversationScreenState: com.wire.android.ui.home.conversations.ConversationScreenState, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, onLinkClick: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit skippable: false restartable: true params: @@ -4678,7 +4399,7 @@ private fun com.wire.android.ui.home.conversations.ConversationScreenContent(ban - onBackButtonClick: STABLE (function type) - composerMessages: RUNTIME (requires runtime check) - conversationMessages: RUNTIME (requires runtime check) - - shareAssetExternally: STABLE (function type) + - shareAsset: STABLE (function type) - shareAssetViaWire: STABLE (function type) - onDownloadAssetClick: STABLE (function type) - onOpenAssetClick: STABLE (function type) @@ -4702,6 +4423,23 @@ private fun com.wire.android.ui.home.conversations.ConversationScreenContent(ban - hasMoreRemoteMessages: STABLE (primitive type) - isWireCellsEnabled: STABLE (primitive type) +@Composable +internal fun com.wire.android.ui.home.conversations.ConversationScreenRouteContent(navigation: com.wire.android.ui.home.conversations.ConversationRouteScreenNavigation, onShareAssetViaWire: kotlin.Function1, conversationInfoViewModel: com.wire.android.ui.home.conversations.info.ConversationInfoViewModel, conversationBannerViewModel: com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel, conversationCallViewModel: com.wire.android.ui.home.conversations.call.ConversationCallViewModel, conversationMessagesViewModel: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel, messageComposerViewModel: com.wire.android.ui.home.conversations.composer.MessageComposerViewModel, sendMessageViewModel: com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel, conversationMigrationViewModel: com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel, messageDraftViewModel: com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel, messageAttachmentsViewModel: com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel): kotlin.Unit + skippable: false + restartable: true + params: + - navigation: RUNTIME (requires runtime check) + - onShareAssetViaWire: STABLE (function type) + - conversationInfoViewModel: UNSTABLE (has mutable properties or unstable members) + - conversationBannerViewModel: UNSTABLE (has mutable properties or unstable members) + - conversationCallViewModel: UNSTABLE (has mutable properties or unstable members) + - conversationMessagesViewModel: UNSTABLE (has mutable properties or unstable members) + - messageComposerViewModel: UNSTABLE (has mutable properties or unstable members) + - sendMessageViewModel: UNSTABLE (has mutable properties or unstable members) + - conversationMigrationViewModel: UNSTABLE (has mutable properties or unstable members) + - messageDraftViewModel: UNSTABLE (has mutable properties or unstable members) + - messageAttachmentsViewModel: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.ConversationScreenTopAppBar(conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, onBackButtonClick: kotlin.Function0, onDropDownClick: kotlin.Function0, onSearchButtonClick: kotlin.Function0, onPhoneButtonClick: kotlin.Function0, hasOngoingCall: kotlin.Boolean, onJoinCallButtonClick: kotlin.Function0, onAudioPermissionPermanentlyDenied: kotlin.Function0, isInteractionEnabled: kotlin.Boolean, isDropDownEnabled: kotlin.Boolean, containerColor: androidx.compose.ui.graphics.Color?): kotlin.Unit skippable: false @@ -4776,6 +4514,14 @@ private fun com.wire.android.ui.home.conversations.MenuItem(iconRes: kotlin.Int, - highlightColor: STABLE (marked @Stable or @Immutable) - onClick: STABLE (function type) +@Composable +private fun com.wire.android.ui.home.conversations.NewConversationFolderNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, viewModel: com.wire.android.ui.home.conversations.folder.NewFolderViewModel): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - viewModel: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable.StartDeletionTimer(message: com.wire.android.ui.home.conversations.model.UIMessage, onSelfDeletingMessageRead: kotlin.Function1): kotlin.Unit skippable: false @@ -4818,10 +4564,11 @@ public fun com.wire.android.ui.home.conversations.UsersTypingIndicatorForConvers - viewModel: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversations.addMembersToConversationViewModel(): com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel - skippable: true +public fun com.wire.android.ui.home.conversations.addMembersToConversationViewModel(args: com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs): com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel + skippable: false restartable: true params: + - args: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.home.conversations.assetLocalPathViewModel(args: com.wire.android.ui.home.conversations.messages.item.AssetLocalPathArgs, keyInScopeResolver: kotlin.Function1<@[ParameterName(name = \): com.wire.android.ui.home.conversations.messages.item.AssetLocalPathViewModel @@ -4876,11 +4623,11 @@ public fun com.wire.android.ui.home.conversations.channels.BrowseChannelsEmptySc - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.conversations.channels.BrowseChannelsScreen(navigator: com.wire.android.navigation.Navigator): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.channels.BrowseChannelsRouteScreen(onCloseSearchClicked: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onCloseSearchClicked: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversations.channels.Content(searchQueryTextState: androidx.compose.foundation.text.input.TextFieldState, onCloseSearchClicked: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -4899,10 +4646,10 @@ public fun com.wire.android.ui.home.conversations.checkAssetRestrictionsViewMode @Composable public fun com.wire.android.ui.home.conversations.compositeMessageViewModel(args: com.wire.android.ui.home.conversations.model.CompositeMessageArgs): com.wire.android.ui.home.conversations.CompositeMessageViewModel - skippable: true + skippable: false restartable: true params: - - args: STABLE (class with no mutable properties) + - args: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.home.conversations.conversationAssetMessagesViewModel(): com.wire.android.ui.home.conversations.media.ConversationAssetMessagesViewModel @@ -4910,6 +4657,13 @@ public fun com.wire.android.ui.home.conversations.conversationAssetMessagesViewM restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.conversationAssetMessagesViewModel(args: com.wire.android.ui.home.conversations.media.ConversationMediaNavArgs): com.wire.android.ui.home.conversations.media.ConversationAssetMessagesViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.conversationAssetPathsViewModel(conversationKey: kotlin.String): com.wire.android.ui.home.conversations.messages.item.ConversationAssetPathsViewModel skippable: true @@ -4923,6 +4677,13 @@ public fun com.wire.android.ui.home.conversations.conversationBannerViewModel(): restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.conversationBannerViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable private fun com.wire.android.ui.home.conversations.conversationCoreViewModel(key: kotlin.String?): VM of com.wire.android.ui.home.conversations.conversationCoreViewModel skippable: true @@ -4930,36 +4691,78 @@ private fun com.wire.android.ui.home.conversations.conversationCoreViewModel(key params: - key: STABLE (class with no mutable properties) +@Composable +private fun com.wire.android.ui.home.conversations.conversationDetailsAssistedViewModel(create: @[ExtensionFunctionType] kotlin.Function1): VM of com.wire.android.ui.home.conversations.conversationDetailsAssistedViewModel + skippable: true + restartable: true + params: + - create: STABLE (function type) + @Composable public fun com.wire.android.ui.home.conversations.conversationFoldersViewModel(args: com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArgs): com.wire.android.ui.home.conversations.folder.ConversationFoldersVM skippable: true restartable: true params: - - args: STABLE (class with no mutable properties) + - args: STABLE (class with no mutable properties) + +@Composable +public fun com.wire.android.ui.home.conversations.conversationInfoViewModel(): com.wire.android.ui.home.conversations.info.ConversationInfoViewModel + skippable: true + restartable: true + params: + +@Composable +public fun com.wire.android.ui.home.conversations.conversationInfoViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.info.ConversationInfoViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + +@Composable +public fun com.wire.android.ui.home.conversations.conversationMessagesViewModel(): com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel + skippable: true + restartable: true + params: + +@Composable +public fun com.wire.android.ui.home.conversations.conversationMessagesViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + +@Composable +public fun com.wire.android.ui.home.conversations.conversationMigrationViewModel(): com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel + skippable: true + restartable: true + params: @Composable -public fun com.wire.android.ui.home.conversations.conversationInfoViewModel(): com.wire.android.ui.home.conversations.info.ConversationInfoViewModel - skippable: true +public fun com.wire.android.ui.home.conversations.conversationMigrationViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel + skippable: false restartable: true params: + - args: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.home.conversations.conversationMessagesViewModel(): com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel +private fun com.wire.android.ui.home.conversations.conversationSearchFolderAssistedViewModel(create: @[ExtensionFunctionType] kotlin.Function1): VM of com.wire.android.ui.home.conversations.conversationSearchFolderAssistedViewModel skippable: true restartable: true params: + - create: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.conversationMigrationViewModel(): com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel +public fun com.wire.android.ui.home.conversations.createPasswordGuestLinkViewModel(): com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel skippable: true restartable: true params: @Composable -public fun com.wire.android.ui.home.conversations.createPasswordGuestLinkViewModel(): com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel - skippable: true +public fun com.wire.android.ui.home.conversations.createPasswordGuestLinkViewModel(args: com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkNavArgs): com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel + skippable: false restartable: true params: + - args: UNSTABLE (has mutable properties or unstable members) @Composable internal fun com.wire.android.ui.home.conversations.delete.DeleteMessageDialog(dialogState: com.wire.android.ui.common.visbility.VisibilityState, deleteMessage: kotlin.Function2<@[ParameterName(name = \): kotlin.Unit @@ -4988,6 +4791,14 @@ private fun com.wire.android.ui.home.conversations.delete.DeleteMessageForYourse - onDialogDismiss: STABLE (function type) - onDeleteForMe: STABLE (function type) +@Composable +private fun com.wire.android.ui.home.conversations.details.EditConversationNameNavigation3Entry(route: com.wire.android.ui.home.conversations.details.EditConversationNameRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + @Composable private fun com.wire.android.ui.home.conversations.details.GroupConversationDetailsContent(groupConversationOptionsState: com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState, sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, onBackPressed: kotlin.Function0, onProfilePressed: kotlin.Function1, onAddParticipantsPressed: kotlin.Function0, onEditGuestAccess: kotlin.Function0, onAppsAccessItemClicked: kotlin.Function0, onChannelAccessItemClicked: kotlin.Function0, onEditSelfDeletingMessages: kotlin.Function0, onEditGroupName: kotlin.Function0, groupParticipantsState: com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsState, isAbandonedOneOnOneConversation: kotlin.Boolean, isWireCellEnabled: kotlin.Boolean, onSearchConversationMessagesClick: kotlin.Function0, onConversationMediaClick: kotlin.Function0, viewModel: com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel?, onMoveToFolder: kotlin.Function1, onLeftConversation: kotlin.Function0, onDeletedConversation: kotlin.Function0, onPromoteAdmin: kotlin.Function2, kotlin.Unit>, openConversationDebugMenu: kotlin.Function1, initialPageIndex: com.wire.android.ui.home.conversations.details.GroupConversationDetailsTabItem, isScreenLoading: kotlinx.coroutines.flow.StateFlow): kotlin.Unit skippable: false @@ -5018,16 +4829,22 @@ private fun com.wire.android.ui.home.conversations.details.GroupConversationDeta - isScreenLoading: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversations.details.GroupConversationDetailsScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, groupConversationDetailResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, editChannelAccessResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, conversationFoldersScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, viewModel: com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel): kotlin.Unit +private fun com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavigation3Entry(route: com.wire.android.ui.home.conversations.details.GroupConversationDetailsRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.conversations.details.ConversationDetailsNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + +@Composable +internal fun com.wire.android.ui.home.conversations.details.GroupConversationDetailsRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel, actions: com.wire.android.ui.home.conversations.details.GroupConversationDetailsRouteScreenActions, renameResult: kotlin.Boolean?): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - - groupConversationDetailResultRecipient: STABLE (marked @Stable or @Immutable) - - editChannelAccessResultRecipient: STABLE (marked @Stable or @Immutable) - - conversationFoldersScreenResultRecipient: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - actions: STABLE (class with no mutable properties) + - renameResult: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.conversations.details.GroupConversationDetailsTopBarCollapsing(title: kotlin.String, totalParticipants: kotlin.Int, isUnderLegalHold: kotlin.Boolean, conversationAvatar: com.wire.android.ui.home.conversations.info.ConversationAvatar.Group, isWireCellEnabled: kotlin.Boolean, onSearchConversationMessagesClick: kotlin.Function0, onConversationMediaClick: kotlin.Function0, onLegalHoldLearnMoreClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -5149,13 +4966,14 @@ public fun com.wire.android.ui.home.conversations.details.editguestaccess.Disabl - onDialogDismiss: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessScreen(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, editGuestAccessViewModel: com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessViewModel, onNavigateBack: kotlin.Function0, onCreatePasswordProtectedLink: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onCreatePasswordProtectedLink: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - - editGuestAccessViewModel: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.home.conversations.details.editguestaccess.GenerateGuestRoomLinkFailureDialog(onDismiss: kotlin.Function0): kotlin.Unit @@ -5239,12 +5057,12 @@ private fun com.wire.android.ui.home.conversations.details.editguestaccess.creat - onCreateLink: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordProtectedGuestLinkScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordProtectedGuestLinkRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkViewModel, navigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - navigateBack: STABLE (function type) @Composable public fun com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordProtectedGuestLinkScreenContent(state: com.wire.android.ui.home.conversations.details.editguestaccess.createPasswordProtectedGuestLink.CreatePasswordGuestLinkState, passwordTextState: androidx.compose.foundation.text.input.TextFieldState, confirmPasswordTextState: androidx.compose.foundation.text.input.TextFieldState, navigateBack: kotlin.Function0, onGenerateRandomPassword: kotlin.Function0, onGenerateLink: kotlin.Function0, onErrorDialogDismissed: kotlin.Function0, observePasswordValidation: kotlin.coroutines.SuspendFunction0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -5262,12 +5080,12 @@ public fun com.wire.android.ui.home.conversations.details.editguestaccess.create - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesScreen(navigator: com.wire.android.navigation.Navigator, editSelfDeletingMessagesViewModel: com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - editSelfDeletingMessagesViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable public fun com.wire.android.ui.home.conversations.details.editselfdeletingmessages.SelectableSelfDeletingItem(duration: com.wire.android.ui.home.messagecomposer.SelfDeletionDuration, isSelected: kotlin.Boolean, onSelfDeletionDurationSelected: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -5336,13 +5154,13 @@ private fun com.wire.android.ui.home.conversations.details.menu.titleRes(state: - state: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.home.conversations.details.metadata.EditConversationNameScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.ui.home.conversations.details.metadata.EditConversationMetadataViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.metadata.EditConversationNameRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.metadata.EditConversationMetadataViewModel, onNavigateBack: kotlin.Function0, onCompleted: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onCompleted: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversations.details.options.ArrowRight(): kotlin.Unit @@ -5506,13 +5324,13 @@ private fun com.wire.android.ui.home.conversations.details.participants.GroupCon - groupParticipantsState: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsNavArgs, viewModel: com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsViewModel, onBackPressed: kotlin.Function0, onProfilePressed: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - navArgs: UNSTABLE (has mutable properties or unstable members) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onProfilePressed: STABLE (function type) @Composable public fun com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipants(onProfilePressed: kotlin.Function1, groupParticipantsState: com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsState, lazyListState: androidx.compose.foundation.lazy.LazyListState, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -5567,20 +5385,20 @@ private fun com.wire.android.ui.home.conversations.details.updateappsaccess.Upda - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessScreen(navigator: com.wire.android.navigation.Navigator, updateAppsAccessViewModel: com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - updateAppsAccessViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.details.updatechannelaccess.ChannelAccessOnUpdateScreen(resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, updateChannelAccessViewModel: com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.details.updatechannelaccess.ChannelAccessOnUpdateRouteScreen(viewModel: com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessViewModel, onNavigateBack: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - resultNavigator: STABLE (marked @Stable or @Immutable) - - updateChannelAccessViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversations.edit.MessageOptionsModalContent(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, sheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState, isNetworkAvailable: kotlin.Boolean, onCopyClick: kotlin.Function1<@[ParameterName(name = \, onDeleteClick: kotlin.Function2<@[ParameterName(name = \, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onDetailsClick: kotlin.Function2<@[ParameterName(name = \, onReplyClick: kotlin.Function1, onEditClick: kotlin.Function4<@[ParameterName(name = \, onShareAssetExternallyClick: kotlin.Function1<@[ParameterName(name = \, onShareAssetViaWireClick: kotlin.Function1<@[ParameterName(name = \, onDownloadAssetClick: kotlin.Function1<@[ParameterName(name = \, onOpenAssetClick: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit @@ -5701,18 +5519,39 @@ public fun com.wire.android.ui.home.conversations.editConversationMetadataViewMo restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.editConversationMetadataViewModel(args: com.wire.android.ui.home.conversations.details.metadata.EditConversationNameNavArgs): com.wire.android.ui.home.conversations.details.metadata.EditConversationMetadataViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.editGuestAccessViewModel(): com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.editGuestAccessViewModel(args: com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessNavArgs): com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.editSelfDeletingMessagesViewModel(): com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.editSelfDeletingMessagesViewModel(args: com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesNavArgs): com.wire.android.ui.home.conversations.details.editselfdeletingmessages.EditSelfDeletingMessagesViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable private fun com.wire.android.ui.home.conversations.folder.Content(args: com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs, foldersState: com.wire.android.ui.home.conversations.folder.ConversationFoldersState, onNavigationPressed: kotlin.Function0, moveConversationToFolder: kotlin.Function1<@[ParameterName(name = \, onFolderSelected: kotlin.Function1<@[ParameterName(name = \, onCreateFolderPressed: kotlin.Function0): kotlin.Unit skippable: false @@ -5737,25 +5576,25 @@ private fun com.wire.android.ui.home.conversations.folder.Content(textState: and - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.conversations.folder.ConversationFoldersScreen(args: com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs, navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, resultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, foldersViewModel: com.wire.android.ui.home.conversations.folder.ConversationFoldersVM, moveToFolderVM: com.wire.android.ui.home.conversations.folder.MoveConversationToFolderVM): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.folder.ConversationFoldersRouteScreen(args: com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs, foldersViewModel: com.wire.android.ui.home.conversations.folder.ConversationFoldersVM, moveToFolderViewModel: com.wire.android.ui.home.conversations.folder.MoveConversationToFolderVM, onNavigateBack: kotlin.Function0, onCompleted: kotlin.Function1, onCreateFolderPressed: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - args: UNSTABLE (has mutable properties or unstable members) - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - - resultRecipient: STABLE (marked @Stable or @Immutable) - foldersViewModel: RUNTIME (requires runtime check) - - moveToFolderVM: RUNTIME (requires runtime check) + - moveToFolderViewModel: RUNTIME (requires runtime check) + - onNavigateBack: STABLE (function type) + - onCompleted: STABLE (function type) + - onCreateFolderPressed: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.folder.NewConversationFolderScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.ui.home.conversations.folder.NewFolderViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.folder.NewConversationFolderRouteScreen(viewModel: com.wire.android.ui.home.conversations.folder.NewFolderViewModel, onBackPressed: kotlin.Function0, onFolderCreated: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onFolderCreated: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversations.folder.computeNameErrorState(error: com.wire.android.ui.home.conversations.folder.FolderNameState.NameError): com.wire.android.ui.common.textfield.WireTextFieldState @@ -5784,18 +5623,39 @@ public fun com.wire.android.ui.home.conversations.groupConversationDetailsViewMo restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.groupConversationDetailsViewModel(args: com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavArgs): com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.groupConversationParticipantsViewModel(): com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.groupConversationParticipantsViewModel(args: com.wire.android.ui.home.conversations.details.participants.GroupConversationAllParticipantsNavArgs): com.wire.android.ui.home.conversations.details.participants.GroupConversationParticipantsViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.imagesPreviewViewModel(): com.wire.android.ui.home.conversations.media.preview.ImagesPreviewViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.imagesPreviewViewModel(args: com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavArgs): com.wire.android.ui.home.conversations.media.preview.ImagesPreviewViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.isFileSharingEnabledViewModel(): com.wire.android.ui.home.messagecomposer.attachments.IsFileSharingEnabledViewModel skippable: true @@ -5856,13 +5716,15 @@ public fun com.wire.android.ui.home.conversations.media.ConversationMediaButton( - onClick: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.media.ConversationMediaScreen(navigator: com.wire.android.navigation.Navigator, conversationAssetMessagesViewModel: com.wire.android.ui.home.conversations.media.ConversationAssetMessagesViewModel, conversationMessagesViewModel: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.media.ConversationMediaRouteScreen(conversationAssetMessagesViewModel: com.wire.android.ui.home.conversations.media.ConversationAssetMessagesViewModel, conversationMessagesViewModel: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel, onNavigateBack: kotlin.Function0, onShareAssetViaWire: kotlin.Function1, onOpenGallery: kotlin.Function4): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - conversationAssetMessagesViewModel: UNSTABLE (has mutable properties or unstable members) - conversationMessagesViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onShareAssetViaWire: STABLE (function type) + - onOpenGallery: STABLE (function type) @Composable public fun com.wire.android.ui.home.conversations.media.ConversationSharedDriveButton(modifier: androidx.compose.ui.Modifier, onClick: kotlin.Function0): kotlin.Unit @@ -5959,14 +5821,14 @@ private fun com.wire.android.ui.home.conversations.media.preview.Content(preview - onRemoveAsset: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.media.preview.ImagesPreviewScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, imagesPreviewViewModel: com.wire.android.ui.home.conversations.media.preview.ImagesPreviewViewModel, checkAssetRestrictionsViewModel: com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.media.preview.ImagesPreviewRouteScreen(imagesPreviewViewModel: com.wire.android.ui.home.conversations.media.preview.ImagesPreviewViewModel, checkAssetRestrictionsViewModel: com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel, onNavigateBack: kotlin.Function0, onCompleted: kotlin.Function1, kotlin.Unit>): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - imagesPreviewViewModel: UNSTABLE (has mutable properties or unstable members) - checkAssetRestrictionsViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onCompleted: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversations.media.preview.ThumbnailsRow(previewState: com.wire.android.ui.home.conversations.media.preview.ImagesPreviewState, onSelected: kotlin.Function1<@[ParameterName(name = \, onRemoveAsset: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit @@ -5984,7 +5846,14 @@ public fun com.wire.android.ui.home.conversations.mediaGalleryViewModel(): com.w params: @Composable -public fun com.wire.android.ui.home.conversations.mention.MemberItemToMention(avatarData: com.wire.android.model.UserAvatarData, name: kotlin.String, label: kotlin.String, membership: com.wire.android.ui.home.conversationslist.model.Membership, searchQuery: kotlin.String, clickable: com.wire.android.model.Clickable, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.ui.home.conversations.mediaGalleryViewModel(args: com.wire.android.ui.home.gallery.MediaGalleryNavArgs): com.wire.android.ui.home.gallery.MediaGalleryViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + +@Composable +public fun com.wire.android.ui.home.conversations.mention.MemberItemToMention(avatarData: com.wire.android.model.UserAvatarData, name: kotlin.String, label: kotlin.String, membership: com.wire.android.ui.home.conversationslist.model.Membership, searchQuery: kotlin.String, clickable: com.wire.android.model.Clickable, modifier: androidx.compose.ui.Modifier, backgroundColor: androidx.compose.ui.graphics.Color): kotlin.Unit skippable: true restartable: true params: @@ -5995,6 +5864,7 @@ public fun com.wire.android.ui.home.conversations.mention.MemberItemToMention(av - searchQuery: STABLE (String is immutable) - clickable: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) + - backgroundColor: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.ui.home.conversations.messageAttachmentsViewModel(): com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel @@ -6002,24 +5872,52 @@ public fun com.wire.android.ui.home.conversations.messageAttachmentsViewModel(): restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.messageAttachmentsViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.messageComposerViewModel(): com.wire.android.ui.home.conversations.composer.MessageComposerViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.messageComposerViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.composer.MessageComposerViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.messageDetailsViewModel(): com.wire.android.ui.home.conversations.messagedetails.MessageDetailsViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.messageDetailsViewModel(args: com.wire.android.ui.home.conversations.messagedetails.MessageDetailsNavArgs): com.wire.android.ui.home.conversations.messagedetails.MessageDetailsViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.messageDraftViewModel(): com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel skippable: true restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.messageDraftViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.messageOptionsMenuViewModel(args: com.wire.android.ui.home.conversations.edit.MessageOptionsMenuArgs): com.wire.android.ui.home.conversations.edit.MessageOptionsMenuViewModel skippable: false @@ -6058,12 +5956,12 @@ public fun com.wire.android.ui.home.conversations.messagedetails.MessageDetailsR - onReadReceiptsLearnMore: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.messagedetails.MessageDetailsScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.conversations.messagedetails.MessageDetailsViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.messagedetails.MessageDetailsRouteScreen(viewModel: com.wire.android.ui.home.conversations.messagedetails.MessageDetailsViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversations.messagedetails.MessageDetailsScreenContent(messageDetailsState: com.wire.android.ui.home.conversations.messagedetails.MessageDetailsState, onBackPressed: kotlin.Function0, onReactionsLearnMore: kotlin.Function0, onReadReceiptsLearnMore: kotlin.Function0): kotlin.Unit @@ -6935,7 +6833,7 @@ public fun com.wire.android.ui.home.conversations.model.MediaAssetImage(asset: c - assetPath: UNSTABLE (has mutable properties or unstable members) @Composable -internal fun com.wire.android.ui.home.conversations.model.MessageBody(messageId: kotlin.String, messageBody: com.wire.android.ui.home.conversations.model.MessageBody?, isAvailable: kotlin.Boolean, accent: com.wire.android.ui.theme.Accent, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, buttonList: kotlinx.collections.immutable.PersistentList?, onLinkClick: kotlin.Function1, searchQuery: kotlin.String, clickable: kotlin.Boolean, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.model.MessageBody(messageId: kotlin.String, messageBody: com.wire.android.ui.home.conversations.model.MessageBody?, isAvailable: kotlin.Boolean, accent: com.wire.android.ui.theme.Accent, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, buttonList: kotlinx.collections.immutable.PersistentList?, onLinkClick: kotlin.Function1, conversationId: com.wire.kalium.logic.data.id.QualifiedID?, searchQuery: kotlin.String, clickable: kotlin.Boolean, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle): kotlin.Unit skippable: false restartable: true params: @@ -6946,15 +6844,17 @@ internal fun com.wire.android.ui.home.conversations.model.MessageBody(messageId: - onOpenProfile: STABLE (function type) - buttonList: STABLE (known stable type) - onLinkClick: STABLE (function type) + - conversationId: UNSTABLE (has mutable properties or unstable members) - searchQuery: STABLE (String is immutable) - clickable: STABLE (primitive type) - messageStyle: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.home.conversations.model.MessageButtonsContent(messageId: kotlin.String, buttonList: kotlin.collections.List, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.home.conversations.CompositeMessageViewModel): kotlin.Unit +public fun com.wire.android.ui.home.conversations.model.MessageButtonsContent(conversationId: com.wire.kalium.logic.data.id.QualifiedID, messageId: kotlin.String, buttonList: kotlin.collections.List, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.home.conversations.CompositeMessageViewModel): kotlin.Unit skippable: false restartable: true params: + - conversationId: STABLE (matched by stability configuration) - messageId: STABLE (String is immutable) - buttonList: RUNTIME (requires runtime check) - messageStyle: STABLE (class with no mutable properties) @@ -7441,10 +7341,11 @@ public fun com.wire.android.ui.home.conversations.newFolderViewModel(): com.wire params: @Composable -public fun com.wire.android.ui.home.conversations.promoteAdminViewModel(): com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel - skippable: true +public fun com.wire.android.ui.home.conversations.promoteAdminViewModel(args: com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs): com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel + skippable: false restartable: true params: + - args: UNSTABLE (has mutable properties or unstable members) @Composable private fun com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminContent(state: com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminState, onSearchQueryChanged: kotlin.Function1, onUserSelected: kotlin.Function1, onPromoteAdminAndLeave: kotlin.Function0, onClose: kotlin.Function0): kotlin.Unit @@ -7458,12 +7359,12 @@ private fun com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminCont - onClose: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminRouteScreen(viewModel: com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable public fun com.wire.android.ui.home.conversations.quotedMultipartMessageViewModel(conversationKey: kotlin.String): com.wire.android.ui.home.conversations.messages.QuotedMultipartMessageViewModel @@ -7496,7 +7397,7 @@ public fun com.wire.android.ui.home.conversations.rememberSelfDeletionTimer(expi - expirationStatus: STABLE (class with no mutable properties) @Composable -private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(arguments: R of com.wire.android.ui.home.conversations.scopedMessageViewModel, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function3): S of com.wire.android.ui.home.conversations.scopedMessageViewModel +private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(arguments: R of com.wire.android.ui.home.conversations.scopedMessageViewModel, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.ui.home.conversations.scopedMessageViewModel skippable: false restartable: true params: @@ -7505,7 +7406,7 @@ private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(argume - create: STABLE (function type) @Composable -private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(arguments: R of com.wire.android.ui.home.conversations.scopedMessageViewModel, keyInScopeResolver: kotlin.Function1<@[ParameterName(name = \, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function3): S of com.wire.android.ui.home.conversations.scopedMessageViewModel +private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(arguments: R of com.wire.android.ui.home.conversations.scopedMessageViewModel, keyInScopeResolver: kotlin.Function1<@[ParameterName(name = \, clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.ui.home.conversations.scopedMessageViewModel skippable: false restartable: true params: @@ -7515,21 +7416,15 @@ private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(argume - create: STABLE (function type) @Composable -private fun com.wire.android.ui.home.conversations.scopedMessageViewModel(clearDelay: kotlin.time.Duration?, create: @[ExtensionFunctionType] kotlin.Function2): S of com.wire.android.ui.home.conversations.scopedMessageViewModel - skippable: true - restartable: true - params: - - clearDelay: STABLE (known stable type) - - create: STABLE (function type) - -@Composable -public fun com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersSearchScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs, addMembersToConversationViewModel: com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersSearchRouteScreen(viewModel: com.wire.android.ui.home.conversations.search.adddembertoconversation.AddMembersToConversationViewModel, navArgs: com.wire.android.ui.home.conversations.search.AddMembersSearchNavArgs, onNavigateBack: kotlin.Function0, onOpenUserProfile: kotlin.Function1, onOpenService: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - viewModel: UNSTABLE (has mutable properties or unstable members) - navArgs: UNSTABLE (has mutable properties or unstable members) - - addMembersToConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onOpenUserProfile: STABLE (function type) + - onOpenService: STABLE (function type) @Composable public fun com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesButton(modifier: androidx.compose.ui.Modifier, onClick: kotlin.Function0): kotlin.Unit @@ -7586,18 +7481,21 @@ public fun com.wire.android.ui.home.conversations.search.messages.SearchConversa - searchQuery: STABLE (String is immutable) @Composable -public fun com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesScreen(navigator: com.wire.android.navigation.Navigator, searchConversationMessagesViewModel: com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesRouteScreen(viewModel: com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel, onMessageClick: kotlin.Function1, onCloseSearchClicked: kotlin.Function0, onSearchFilesButtonClick: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - searchConversationMessagesViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onMessageClick: STABLE (function type) + - onCloseSearchClicked: STABLE (function type) + - onSearchFilesButtonClick: STABLE (function type) @Composable -public fun com.wire.android.ui.home.conversations.searchConversationMessagesViewModel(): com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel - skippable: true +public fun com.wire.android.ui.home.conversations.searchConversationMessagesViewModel(args: com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesNavArgs): com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel + skippable: false restartable: true params: + - args: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.home.conversations.selfDeletingMessageActionViewModel(args: com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionArgs): com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModel @@ -7638,6 +7536,13 @@ public fun com.wire.android.ui.home.conversations.sendMessageViewModel(): com.wi restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.sendMessageViewModel(args: com.wire.android.ui.home.conversations.ConversationNavArgs): com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable private fun com.wire.android.ui.home.conversations.stringResourceProvider(): com.wire.android.ui.home.conversations.StringResourceProvider skippable: true @@ -7657,6 +7562,13 @@ public fun com.wire.android.ui.home.conversations.updateAppsAccessViewModel(): c restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.updateAppsAccessViewModel(args: com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessNavArgs): com.wire.android.ui.home.conversations.details.updateappsaccess.UpdateAppsAccessViewModel + skippable: false + restartable: true + params: + - args: UNSTABLE (has mutable properties or unstable members) + @Composable public fun com.wire.android.ui.home.conversations.updateChannelAccessViewModel(): com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessViewModel skippable: true @@ -7664,19 +7576,18 @@ public fun com.wire.android.ui.home.conversations.updateChannelAccessViewModel() params: @Composable -public fun com.wire.android.ui.home.conversations.videoplayer.VideoPlayerScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.home.conversations.videoplayer.VideoPlayerNavArgs): kotlin.Unit +public fun com.wire.android.ui.home.conversations.updateChannelAccessViewModel(args: com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessArgs): com.wire.android.ui.home.conversations.details.updatechannelaccess.UpdateChannelAccessViewModel skippable: true restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - navArgs: STABLE (class with no mutable properties) + - args: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent(navigator: com.wire.android.navigation.Navigator, searchBarState: com.wire.android.ui.common.search.SearchBarState, modifier: androidx.compose.ui.Modifier, emptyListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, lazyListState: androidx.compose.foundation.lazy.LazyListState, loadingListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, conversationsSource: com.wire.android.ui.home.conversationslist.model.ConversationsSource, emptySearchResultFocusRequester: androidx.compose.ui.focus.FocusRequester?, firstConversationFocusRequester: androidx.compose.ui.focus.FocusRequester?, onConversationOpened: kotlin.Function0, conversationListCallViewModel: com.wire.android.ui.home.conversationslist.ConversationListCallViewModel, conversationListViewModel: com.wire.android.ui.home.conversationslist.ConversationListViewModel): kotlin.Unit +public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent(navigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions, searchBarState: com.wire.android.ui.common.search.SearchBarState, modifier: androidx.compose.ui.Modifier, emptyListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, lazyListState: androidx.compose.foundation.lazy.LazyListState, loadingListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, conversationsSource: com.wire.android.ui.home.conversationslist.model.ConversationsSource, emptySearchResultFocusRequester: androidx.compose.ui.focus.FocusRequester?, firstConversationFocusRequester: androidx.compose.ui.focus.FocusRequester?, onConversationOpened: kotlin.Function0, conversationListCallViewModel: com.wire.android.ui.home.conversationslist.ConversationListCallViewModel, conversationListViewModel: com.wire.android.ui.home.conversationslist.ConversationListViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - navigationActions: STABLE (class with no mutable properties) - searchBarState: UNSTABLE (has mutable properties or unstable members) - modifier: STABLE (marked @Stable or @Immutable) - emptyListContent: STABLE (composable function type) @@ -7690,30 +7601,31 @@ public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent - conversationListViewModel: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversationslist.all.AllConversationsScreen(homeStateHolder: com.wire.android.ui.home.HomeStateHolder, foldersViewModel: com.wire.android.ui.home.conversations.folder.ConversationFoldersVM): kotlin.Unit +internal fun com.wire.android.ui.home.conversationslist.all.AllConversationsContent(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions, foldersViewModel: com.wire.android.ui.home.conversations.folder.ConversationFoldersVM): kotlin.Unit skippable: false restartable: true params: - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - homeShellState: RUNTIME (requires runtime check) + - navigationActions: STABLE (class with no mutable properties) - foldersViewModel: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.conversationslist.all.ConversationsEmptyContent(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, filter: com.wire.kalium.logic.data.conversation.ConversationFilter, domain: kotlin.String): kotlin.Unit +public fun com.wire.android.ui.home.conversationslist.all.ConversationsEmptyContent(onBrowseChannels: kotlin.Function0, modifier: androidx.compose.ui.Modifier, filter: com.wire.kalium.logic.data.conversation.ConversationFilter, domain: kotlin.String): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBrowseChannels: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - filter: UNSTABLE (has mutable properties or unstable members) - domain: STABLE (String is immutable) @Composable -private fun com.wire.android.ui.home.conversationslist.all.EmptyContentFooter(currentFilter: com.wire.kalium.logic.data.conversation.ConversationFilter, navigator: com.wire.android.navigation.Navigator): kotlin.Unit +private fun com.wire.android.ui.home.conversationslist.all.EmptyContentFooter(currentFilter: com.wire.kalium.logic.data.conversation.ConversationFilter, onBrowseChannels: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - currentFilter: UNSTABLE (has mutable properties or unstable members) - - navigator: STABLE (marked @Stable or @Immutable) + - onBrowseChannels: STABLE (function type) @Composable private fun com.wire.android.ui.home.conversationslist.all.emptyDescription(backendName: kotlin.String): kotlin.String @@ -7999,22 +7911,22 @@ public fun com.wire.android.ui.home.conversationslist.search.SearchConversations @Composable public fun com.wire.android.ui.home.drawer.DrawerItem(destination: com.wire.android.navigation.HomeDestination, selected: kotlin.Boolean, onItemClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier, unreadCount: kotlin.Int): kotlin.Unit - skippable: false + skippable: true restartable: true params: - - destination: RUNTIME (requires runtime check) + - destination: STABLE (class with no mutable properties) - selected: STABLE (primitive type) - onItemClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - unreadCount: STABLE (primitive type) @Composable -public fun com.wire.android.ui.home.drawer.HomeDrawer(homeDrawerState: com.wire.android.ui.home.drawer.HomeDrawerState, currentRoute: kotlin.String?, navigateToHomeItem: kotlin.Function1, onCloseDrawer: kotlin.Function0, modifier: androidx.compose.ui.Modifier, isFocusTrapEnabled: kotlin.Boolean, firstItemFocusRequester: androidx.compose.ui.focus.FocusRequester?, lastItemFocusRequester: androidx.compose.ui.focus.FocusRequester?): kotlin.Unit +public fun com.wire.android.ui.home.drawer.HomeDrawer(homeDrawerState: com.wire.android.ui.home.drawer.HomeDrawerState, currentDestination: com.wire.android.navigation.HomeDestination, navigateToHomeItem: kotlin.Function1, onCloseDrawer: kotlin.Function0, modifier: androidx.compose.ui.Modifier, isFocusTrapEnabled: kotlin.Boolean, firstItemFocusRequester: androidx.compose.ui.focus.FocusRequester?, lastItemFocusRequester: androidx.compose.ui.focus.FocusRequester?): kotlin.Unit skippable: true restartable: true params: - homeDrawerState: STABLE (class with no mutable properties) - - currentRoute: STABLE (class with no mutable properties) + - currentDestination: STABLE (class with no mutable properties) - navigateToHomeItem: STABLE (function type) - onCloseDrawer: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @@ -8023,14 +7935,14 @@ public fun com.wire.android.ui.home.drawer.HomeDrawer(homeDrawerState: com.wire. - lastItemFocusRequester: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.drawer.MapToDrawerItem(navigateToHomeItem: kotlin.Function1, onCloseDrawer: kotlin.Function0, currentRoute: kotlin.String?, drawerUiItem: com.wire.android.ui.home.drawer.DrawerUiItem): kotlin.Unit - skippable: false +public fun com.wire.android.ui.home.drawer.MapToDrawerItem(navigateToHomeItem: kotlin.Function1, onCloseDrawer: kotlin.Function0, currentDestination: com.wire.android.navigation.HomeDestination, drawerUiItem: com.wire.android.ui.home.drawer.DrawerUiItem): kotlin.Unit + skippable: true restartable: true params: - navigateToHomeItem: STABLE (function type) - onCloseDrawer: STABLE (function type) - - currentRoute: STABLE (class with no mutable properties) - - drawerUiItem: RUNTIME (requires runtime check) + - currentDestination: STABLE (class with no mutable properties) + - drawerUiItem: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.featureFlagNotificationViewModel(): com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel @@ -8058,12 +7970,15 @@ private fun com.wire.android.ui.home.gallery.MediaGalleryOptionsBottomSheetLayou - onDismiss: STABLE (function type) @Composable -public fun com.wire.android.ui.home.gallery.MediaGalleryScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true +internal fun com.wire.android.ui.home.gallery.MediaGalleryRouteScreen(mediaGalleryViewModel: com.wire.android.ui.home.gallery.MediaGalleryViewModel, onNavigateBack: kotlin.Function0, onResult: kotlin.Function1, onOpenPublicLink: kotlin.Function3, onShareAssetViaWire: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit + skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) + - mediaGalleryViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onResult: STABLE (function type) + - onOpenPublicLink: STABLE (function type) + - onShareAssetViaWire: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -8097,20 +8012,14 @@ public fun com.wire.android.ui.home.homeViewModel(): com.wire.android.ui.home.Ho params: @Composable -public fun com.wire.android.ui.home.meetings.MeetingsScreen(homeStateHolder: com.wire.android.ui.home.HomeStateHolder, viewModel: com.wire.android.ui.home.meetings.MeetingsCallViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.meetings.MeetingsScreen(homeShellState: com.wire.android.ui.home.HomeShellState, onOpenNewMeeting: kotlin.Function1, viewModel: com.wire.android.ui.home.meetings.MeetingsCallViewModel): kotlin.Unit skippable: false restartable: true params: - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - homeShellState: RUNTIME (requires runtime check) + - onOpenNewMeeting: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) -@Composable -private fun com.wire.android.ui.home.meetings.NotEstablishedDialog(dialogState: com.wire.android.ui.common.visbility.VisibilityState): kotlin.Unit - skippable: false - restartable: true - params: - - dialogState: UNSTABLE (has mutable properties or unstable members) - @Composable public fun com.wire.android.ui.home.messagecomposer.ActiveMessageComposerInput(conversationId: com.wire.kalium.logic.data.id.QualifiedID, messageComposition: com.wire.android.ui.home.messagecomposer.model.MessageComposition, messageTextState: androidx.compose.foundation.text.input.TextFieldState, isTextExpanded: kotlin.Boolean, inputType: com.wire.android.ui.home.messagecomposer.state.InputType, focusRequester: androidx.compose.ui.focus.FocusRequester, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, onHardwareEnter: kotlin.Function1<@[ParameterName(name = \, canSendMessage: kotlin.Boolean, onSendButtonClicked: kotlin.Function0, onEditButtonClicked: kotlin.Function0, onChangeSelfDeletionClicked: kotlin.Function1<@[ParameterName(name = \, onToggleInputSize: kotlin.Function0, onCancelReply: kotlin.Function0, onCancelEdit: kotlin.Function0, onFocused: kotlin.Function0, onSelectedLineIndexChanged: kotlin.Function1, onLineBottomYCoordinateChanged: kotlin.Function1, showOptions: kotlin.Boolean, showInlinePlusButton: kotlin.Boolean, optionsSelected: kotlin.Boolean, onPlusClick: kotlin.Function0, useKeyboardActivationGate: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, onHardwareTab: kotlin.Function1<@[ParameterName(name = \, onHardwareEscape: kotlin.Function0): kotlin.Unit skippable: false @@ -8980,6 +8889,50 @@ public fun com.wire.android.ui.home.newConversationViewModel(viewModelStoreOwner params: - viewModelStoreOwner: RUNTIME (requires runtime check) +@Composable +private fun com.wire.android.ui.home.newconversation.ChannelAccessOnCreateNavigation3Entry(route: com.wire.android.ui.home.newconversation.ChannelAccessOnCreateRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + +@Composable +private fun com.wire.android.ui.home.newconversation.GroupOptionNavigation3Entry(route: com.wire.android.ui.home.newconversation.GroupOptionRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.newconversation.NewConversationNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.home.newconversation.NewConversationSearchPeopleNavigation3Entry(route: com.wire.android.ui.home.newconversation.NewConversationSearchPeopleRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.newconversation.NewConversationNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.home.newconversation.NewGroupConversationSearchPeopleNavigation3Entry(route: com.wire.android.ui.home.newconversation.NewGroupConversationSearchPeopleRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.newconversation.NewConversationNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.home.newconversation.NewGroupNameNavigation3Entry(route: com.wire.android.ui.home.newconversation.NewGroupNameRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.newconversation.NewConversationNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + @Composable public fun com.wire.android.ui.home.newconversation.channelaccess.AccessScreenItem(channelAccessType: com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType, selectedAccess: com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType, modifier: androidx.compose.ui.Modifier, isEnabled: kotlin.Boolean, onItemClicked: kotlin.Function1): kotlin.Unit skippable: true @@ -9001,12 +8954,12 @@ private fun com.wire.android.ui.home.newconversation.channelaccess.AccessSection - onAccessChange: STABLE (function type) @Composable -public fun com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessOnCreateScreen(navigator: com.wire.android.navigation.Navigator, newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessOnCreateRouteScreen(newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable public fun com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessScreenContent(internalPadding: androidx.compose.foundation.layout.PaddingValues, selectedAccess: com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType, selectedPermission: com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType, modifier: androidx.compose.ui.Modifier, onAccessChange: kotlin.Function1, onPermissionChange: kotlin.Function1): kotlin.Unit @@ -9040,12 +8993,20 @@ private fun com.wire.android.ui.home.newconversation.channelaccess.PermissionSec - onPermissionChange: STABLE (function type) @Composable -public fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryCustomScreen(navArgs: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryCustomArgs, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, modifier: androidx.compose.ui.Modifier): kotlin.Unit +private fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryCustomNavigation3Entry(route: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryCustomRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + +@Composable +internal fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryCustomRouteScreen(currentType: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryType, onNavigateBack: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - - navArgs: RUNTIME (requires runtime check) - - resultNavigator: STABLE (marked @Stable or @Immutable) + - currentType: RUNTIME (requires runtime check) + - onNavigateBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) @Composable @@ -9066,14 +9027,12 @@ private fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHisto params: @Composable -public fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryScreen(navigator: com.wire.android.navigation.Navigator, newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel, customResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: false +private fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryNavigation3Entry(route: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) - - customResultRecipient: STABLE (marked @Stable or @Immutable) - - modifier: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryScreenContent(selectedHistoryOption: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryType, onHistoryOptionSelected: kotlin.Function1, onOpenCustomChooser: kotlin.Function0, onBackPressed: kotlin.Function0, modifier: androidx.compose.ui.Modifier, isFreemiumAccount: kotlin.Boolean): kotlin.Unit @@ -9204,12 +9163,17 @@ private fun com.wire.android.ui.home.newconversation.groupOptions.EnableWireCell - onEnableWireCell: STABLE (function type) @Composable -public fun com.wire.android.ui.home.newconversation.groupOptions.GroupOptionScreen(navigator: com.wire.android.navigation.Navigator, newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.newconversation.groupOptions.GroupOptionRouteScreen(newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel, onConversationCreated: kotlin.Function1, onOpenAccess: kotlin.Function0, onOpenHistory: kotlin.Function0, onEditParticipants: kotlin.Function0, onDiscard: kotlin.Function0, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - onConversationCreated: STABLE (function type) + - onOpenAccess: STABLE (function type) + - onOpenHistory: STABLE (function type) + - onEditParticipants: STABLE (function type) + - onDiscard: STABLE (function type) + - onNavigateBack: STABLE (function type) @Composable private fun com.wire.android.ui.home.newconversation.groupOptions.GroupOptionScreenContent(groupOptionState: com.wire.android.ui.home.newconversation.groupOptions.GroupOptionState, createGroupState: com.wire.android.ui.home.newconversation.common.CreateGroupState, groupMetadataState: com.wire.android.ui.common.groupname.GroupMetadataState, onAccessClicked: kotlin.Function0, onHistoryClicked: kotlin.Function0, onAllowGuestChanged: kotlin.Function1, onAllowServicesChanged: kotlin.Function1, onReadReceiptChanged: kotlin.Function1, onEnableWireCellChanged: kotlin.Function1, onContinuePressed: kotlin.Function0, onAllowGuestsDialogDismissed: kotlin.Function0, onNotAllowGuestsClicked: kotlin.Function0, onAllowGuestsClicked: kotlin.Function0, onErrorDismissed: kotlin.Function0, onEditParticipantsClick: kotlin.Function0, onDiscardGroupCreationClick: kotlin.Function0, onBackPressed: kotlin.Function0, channelsHistoryOptionsEnabled: kotlin.Boolean, mlsReadReceiptsEnabled: kotlin.Boolean): kotlin.Unit @@ -9254,73 +9218,152 @@ private fun com.wire.android.ui.home.newconversation.groupOptions.GroupOptionsSc - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.newconversation.groupOptions.HistoryOptions(historyType: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryType, onClicked: kotlin.Function0): kotlin.Unit +public fun com.wire.android.ui.home.newconversation.groupOptions.HistoryOptions(historyType: com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryType, onClicked: kotlin.Function0): kotlin.Unit + skippable: false + restartable: true + params: + - historyType: RUNTIME (requires runtime check) + - onClicked: STABLE (function type) + +@Composable +private fun com.wire.android.ui.home.newconversation.groupOptions.PreviewGroupOptionScreen(groupMetadataState: com.wire.android.ui.common.groupname.GroupMetadataState, channelsHistoryOptionsEnabled: kotlin.Boolean, mlsReadReceiptsEnabled: kotlin.Boolean, withAppsEnabled: com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult): kotlin.Unit + skippable: false + restartable: true + params: + - groupMetadataState: RUNTIME (requires runtime check) + - channelsHistoryOptionsEnabled: STABLE (primitive type) + - mlsReadReceiptsEnabled: STABLE (primitive type) + - withAppsEnabled: UNSTABLE (has mutable properties or unstable members) + +@Composable +private fun com.wire.android.ui.home.newconversation.groupOptions.ReadReceiptsOptions(isChannel: kotlin.Boolean, onReadReceiptChanged: kotlin.Function1): kotlin.Unit + skippable: true + restartable: true + params: + - isChannel: STABLE (primitive type) + - onReadReceiptChanged: STABLE (function type) + +@Composable +internal fun com.wire.android.ui.home.newconversation.groupname.NewGroupNameRouteScreen(newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel, onConversationCreated: kotlin.Function1, onContinueToOptions: kotlin.Function0, onEditParticipants: kotlin.Function0, onDiscard: kotlin.Function0, onNavigateBack: kotlin.Function0): kotlin.Unit + skippable: false + restartable: true + params: + - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - onConversationCreated: STABLE (function type) + - onContinueToOptions: STABLE (function type) + - onEditParticipants: STABLE (function type) + - onDiscard: STABLE (function type) + - onNavigateBack: STABLE (function type) + +@Composable +internal fun com.wire.android.ui.home.newconversation.groupsearch.NewGroupConversationSearchPeopleRouteScreen(newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel, onNavigateBack: kotlin.Function0, onOpenUserProfile: kotlin.Function2<@[ParameterName(name = \, onContinue: kotlin.Function0): kotlin.Unit + skippable: false + restartable: true + params: + - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onOpenUserProfile: STABLE (function type) + - onContinue: STABLE (function type) + +@Composable +private fun com.wire.android.ui.home.newconversation.newConversationFlowViewModel(flowId: kotlin.String): com.wire.android.ui.home.newconversation.NewConversationViewModel + skippable: true + restartable: true + params: + - flowId: STABLE (String is immutable) + +@Composable +public fun com.wire.android.ui.home.newconversation.search.ChannelNotAvailableDialog(onCreateTeam: kotlin.Function0, onDismiss: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit + skippable: true + restartable: true + params: + - onCreateTeam: STABLE (function type) + - onDismiss: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) + +@Composable +internal fun com.wire.android.ui.home.newconversation.search.NewConversationSearchPeopleRouteScreen(newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel, onNavigateBack: kotlin.Function0, onOpenUserProfile: kotlin.Function2<@[ParameterName(name = \, onOpenServiceDetails: kotlin.Function3<@[ParameterName(name = \, onStartGroupOrChannel: kotlin.Function0, onOpenTeamPlan: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - historyType: RUNTIME (requires runtime check) - - onClicked: STABLE (function type) + - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onOpenUserProfile: STABLE (function type) + - onOpenServiceDetails: STABLE (function type) + - onStartGroupOrChannel: STABLE (function type) + - onOpenTeamPlan: STABLE (function type) @Composable -private fun com.wire.android.ui.home.newconversation.groupOptions.PreviewGroupOptionScreen(groupMetadataState: com.wire.android.ui.common.groupname.GroupMetadataState, channelsHistoryOptionsEnabled: kotlin.Boolean, mlsReadReceiptsEnabled: kotlin.Boolean, withAppsEnabled: com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult): kotlin.Unit +private fun com.wire.android.ui.home.rememberHomeNavigation3ShellState(selectedDestinationState: androidx.compose.runtime.MutableState, coroutineScope: kotlinx.coroutines.CoroutineScope, drawerState: androidx.compose.material3.DrawerState): com.wire.android.ui.home.HomeNavigation3ShellState skippable: false restartable: true params: - - groupMetadataState: RUNTIME (requires runtime check) - - channelsHistoryOptionsEnabled: STABLE (primitive type) - - mlsReadReceiptsEnabled: STABLE (primitive type) - - withAppsEnabled: UNSTABLE (has mutable properties or unstable members) + - selectedDestinationState: STABLE (marked @Stable or @Immutable) + - coroutineScope: RUNTIME (requires runtime check) + - drawerState: STABLE (marked @Stable or @Immutable) @Composable -private fun com.wire.android.ui.home.newconversation.groupOptions.ReadReceiptsOptions(isChannel: kotlin.Boolean, onReadReceiptChanged: kotlin.Function1): kotlin.Unit - skippable: true +private fun com.wire.android.ui.home.settings.AboutThisAppNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.home.settings.AboutThisAppRoute, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit + skippable: false restartable: true params: - - isChannel: STABLE (primitive type) - - onReadReceiptChanged: STABLE (function type) + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.newconversation.groupname.NewGroupNameScreen(navigator: com.wire.android.navigation.Navigator, newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel): kotlin.Unit +private fun com.wire.android.ui.home.settings.ChangeDisplayNameNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.newconversation.groupsearch.NewGroupConversationSearchPeopleScreen(navigator: com.wire.android.navigation.Navigator, newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel): kotlin.Unit +private fun com.wire.android.ui.home.settings.ChangeEmailNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.home.settings.ChangeEmailRoute, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.newconversation.search.ChannelNotAvailableDialog(onCreateTeam: kotlin.Function0, onDismiss: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true +private fun com.wire.android.ui.home.settings.ChangeHandleNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit + skippable: false restartable: true params: - - onCreateTeam: STABLE (function type) - - onDismiss: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.newconversation.search.NewConversationSearchPeopleScreen(navigator: com.wire.android.navigation.Navigator, newConversationViewModel: com.wire.android.ui.home.newconversation.NewConversationViewModel): kotlin.Unit +private fun com.wire.android.ui.home.settings.ChangeUserColorNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - newConversationViewModel: UNSTABLE (has mutable properties or unstable members) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.home.settings.ConsumeAccountUpdateResult(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.home.settings.MyAccountRoute, requestIdValue: kotlin.String?, kind: com.wire.android.ui.home.settings.account.MyAccountUpdateKind, onConsumed: kotlin.Function0, onNotification: kotlin.Function1): kotlin.Unit + skippable: true + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - requestIdValue: STABLE (class with no mutable properties) + - kind: STABLE (class with no mutable properties) + - onConsumed: STABLE (function type) + - onNotification: STABLE (function type) @Composable -public fun com.wire.android.ui.home.rememberHomeScreenState(navigator: com.wire.android.navigation.Navigator, coroutineScope: kotlinx.coroutines.CoroutineScope, navController: androidx.navigation.NavHostController, drawerState: androidx.compose.material3.DrawerState): com.wire.android.ui.home.HomeStateHolder +private fun com.wire.android.ui.home.settings.MyAccountNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.home.settings.MyAccountRoute, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - coroutineScope: RUNTIME (requires runtime check) - - navController: UNSTABLE (has mutable properties or unstable members) - - drawerState: STABLE (marked @Stable or @Immutable) + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) @Composable public fun com.wire.android.ui.home.settings.SettingsItem(text: kotlin.String, modifier: androidx.compose.ui.Modifier, title: kotlin.String?, maxTitleLines: kotlin.Int, wrapTitleContentWidth: kotlin.Boolean, trailingIcon: kotlin.Int?, trailingText: kotlin.String?, switchState: com.wire.android.ui.home.settings.SwitchState, onRowPressed: com.wire.android.model.Clickable, onIconPressed: com.wire.android.model.Clickable?): kotlin.Unit @@ -9338,6 +9381,16 @@ public fun com.wire.android.ui.home.settings.SettingsItem(text: kotlin.String, m - onRowPressed: STABLE (class with no mutable properties) - onIconPressed: STABLE (class with no mutable properties) +@Composable +internal fun com.wire.android.ui.home.settings.SettingsNavigation3Root(sessionId: com.wire.navigation.WireSessionId, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions, lazyListState: androidx.compose.foundation.lazy.LazyListState): kotlin.Unit + skippable: false + restartable: true + params: + - sessionId: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + - lazyListState: STABLE (marked @Stable or @Immutable) + @Composable public fun com.wire.android.ui.home.settings.SettingsOptionSwitch(switchState: com.wire.android.ui.home.settings.SwitchState, modifier: androidx.compose.ui.Modifier, trailingOnText: kotlin.String?): kotlin.Unit skippable: true @@ -9348,11 +9401,13 @@ public fun com.wire.android.ui.home.settings.SettingsOptionSwitch(switchState: c - trailingOnText: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.home.settings.SettingsScreen(homeStateHolder: com.wire.android.ui.home.HomeStateHolder, viewModel: com.wire.android.ui.home.settings.SettingsViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.SettingsScreen(onOpenAppLockSetup: kotlin.Function0, onDirectionItemClicked: kotlin.Function1, lazyListState: androidx.compose.foundation.lazy.LazyListState, viewModel: com.wire.android.ui.home.settings.SettingsViewModel): kotlin.Unit skippable: false restartable: true params: - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - onOpenAppLockSetup: STABLE (function type) + - onDirectionItemClicked: STABLE (function type) + - lazyListState: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -9374,6 +9429,15 @@ public fun com.wire.android.ui.home.settings.TurnAppLockOffDialog(dialogState: c - dialogState: UNSTABLE (has mutable properties or unstable members) - turnOff: STABLE (function type) +@Composable +private fun com.wire.android.ui.home.settings.VerifyEmailNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.home.settings.VerifyEmailRoute, actions: com.wire.android.ui.home.settings.SettingsNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + @Composable private fun com.wire.android.ui.home.settings.about.dependencies.DependenciesContent(internalPadding: androidx.compose.foundation.layout.PaddingValues, dependencies: kotlinx.collections.immutable.ImmutableMap): kotlin.Unit skippable: true @@ -9391,11 +9455,11 @@ private fun com.wire.android.ui.home.settings.about.dependencies.DependenciesIte - dependencyVersion: STABLE (String is immutable) @Composable -public fun com.wire.android.ui.home.settings.about.dependencies.DependenciesScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.about.dependencies.DependenciesViewModel): kotlin.Unit +public fun com.wire.android.ui.home.settings.about.dependencies.DependenciesScreen(onBackPressed: kotlin.Function0, viewModel: com.wire.android.ui.home.settings.about.dependencies.DependenciesViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -9418,11 +9482,11 @@ public fun com.wire.android.ui.home.settings.about.licenses.LicensesContent(inte - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.about.licenses.LicensesScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.about.licenses.LicensesViewModel): kotlin.Unit +public fun com.wire.android.ui.home.settings.about.licenses.LicensesScreen(onBackPressed: kotlin.Function0, viewModel: com.wire.android.ui.home.settings.about.licenses.LicensesViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -9445,17 +9509,6 @@ public fun com.wire.android.ui.home.settings.about.licenses.WireLicenseDialog(li - library: UNSTABLE (has mutable properties or unstable members) - onDismiss: STABLE (function type) -@Composable -private fun com.wire.android.ui.home.settings.account.HandleNavResult(scope: kotlinx.coroutines.CoroutineScope, resultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, tryAgainSnackBarMessage: kotlin.String, successSnackBarMessage: kotlin.String, snackbarHostState: androidx.compose.material3.SnackbarHostState): kotlin.Unit - skippable: false - restartable: true - params: - - scope: RUNTIME (requires runtime check) - - resultRecipient: STABLE (marked @Stable or @Immutable) - - tryAgainSnackBarMessage: STABLE (String is immutable) - - successSnackBarMessage: STABLE (String is immutable) - - snackbarHostState: STABLE (marked @Stable or @Immutable) - @Composable public fun com.wire.android.ui.home.settings.account.MyAccountContent(accountDetailItems: kotlinx.collections.immutable.ImmutableList, forgotPasswordUrl: kotlin.String?, canDeleteAccount: kotlin.Boolean, onDeleteAccountClicked: kotlin.Function0, onDeleteAccountConfirmed: kotlin.Function0, onDeleteAccountDismissed: kotlin.Function0, startDeleteAccountFlow: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: true @@ -9472,16 +9525,18 @@ public fun com.wire.android.ui.home.settings.account.MyAccountContent(accountDet - onNavigateBack: STABLE (function type) @Composable -public fun com.wire.android.ui.home.settings.account.MyAccountScreen(navigator: com.wire.android.navigation.Navigator, changeDisplayNameResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, changeHandleResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, changeUserColorResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, viewModel: com.wire.android.ui.home.settings.account.MyAccountViewModel, deleteAccountViewModel: com.wire.android.ui.home.settings.account.deleteAccount.DeleteAccountViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.account.MyAccountRouteScreen(viewModel: com.wire.android.ui.home.settings.account.MyAccountViewModel, deleteAccountViewModel: com.wire.android.ui.home.settings.account.deleteAccount.DeleteAccountViewModel, onNavigateBack: kotlin.Function0, onChangeDisplayName: kotlin.Function0, onChangeHandle: kotlin.Function0, onChangeEmail: kotlin.Function0, onChangeUserColor: kotlin.Function0, updateNotification: com.wire.android.ui.home.settings.account.MyAccountUpdateNotification?): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - changeDisplayNameResultRecipient: STABLE (marked @Stable or @Immutable) - - changeHandleResultRecipient: STABLE (marked @Stable or @Immutable) - - changeUserColorResultRecipient: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) - deleteAccountViewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onChangeDisplayName: STABLE (function type) + - onChangeHandle: STABLE (function type) + - onChangeEmail: STABLE (function type) + - onChangeUserColor: STABLE (function type) + - updateNotification: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.settings.account.color.AccentCircle(accent: com.wire.android.ui.theme.Accent, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -9503,13 +9558,13 @@ public fun com.wire.android.ui.home.settings.account.color.ChangeUserColorConten - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.account.color.ChangeUserColorScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.ui.home.settings.account.color.ChangeUserColorViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.account.color.ChangeUserColorRouteScreen(viewModel: com.wire.android.ui.home.settings.account.color.ChangeUserColorViewModel, onBackPressed: kotlin.Function0, onCompleted: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onCompleted: STABLE (function type) @Composable public fun com.wire.android.ui.home.settings.account.deleteAccount.DeleteAccountDialog(onDismiss: kotlin.Function0, onConfirm: kotlin.Function0): kotlin.Unit @@ -9531,13 +9586,13 @@ public fun com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNa - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameRouteScreen(viewModel: com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameViewModel, onBackPressed: kotlin.Function0, onCompleted: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onCompleted: STABLE (function type) @Composable private fun com.wire.android.ui.home.settings.account.displayname.computeNameErrorState(error: com.wire.android.model.DisplayNameState.NameError): com.wire.android.ui.common.textfield.WireTextFieldState @@ -9558,12 +9613,13 @@ public fun com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEma - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailRouteScreen(viewModel: com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailViewModel, onBackPressed: kotlin.Function0, onVerifyEmail: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onVerifyEmail: STABLE (function type) @Composable private fun com.wire.android.ui.home.settings.account.email.updateEmail.computeEmailErrorState(state: com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailState.FlowState): com.wire.android.ui.common.textfield.WireTextFieldState @@ -9584,12 +9640,12 @@ public fun com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEma - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailRouteScreen(viewModel: com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel, onBackPressed: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) @Composable public fun com.wire.android.ui.home.settings.account.handle.ChangeHandleContent(textState: androidx.compose.foundation.text.input.TextFieldState, state: com.wire.android.ui.home.settings.account.handle.ChangeHandleState, onSaveClicked: kotlin.Function0, onErrorDismiss: kotlin.Function0, onBackPressed: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -9604,13 +9660,13 @@ public fun com.wire.android.ui.home.settings.account.handle.ChangeHandleContent( - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.account.handle.ChangeHandleScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.ui.home.settings.account.handle.ChangeHandleViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.settings.account.handle.ChangeHandleRouteScreen(viewModel: com.wire.android.ui.home.settings.account.handle.ChangeHandleViewModel, onBackPressed: kotlin.Function0, onCompleted: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onCompleted: STABLE (function type) @Composable public fun com.wire.android.ui.home.settings.appUnlockWithBiometricsViewModel(): com.wire.android.ui.home.appLock.unlock.AppUnlockWithBiometricsViewModel @@ -9628,11 +9684,11 @@ public fun com.wire.android.ui.home.settings.appearance.CustomizationOptionsCont - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.appearance.CustomizationScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.appearance.CustomizationViewModel): kotlin.Unit +public fun com.wire.android.ui.home.settings.appearance.CustomizationScreen(onBackPressed: kotlin.Function0, viewModel: com.wire.android.ui.home.settings.appearance.CustomizationViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -9664,11 +9720,11 @@ public fun com.wire.android.ui.home.settings.appsettings.AppSettingsScreen(): ko params: @Composable -public fun com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsScreen(navigator: com.wire.android.navigation.Navigator, networkSettingsViewModel: com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsViewModel): kotlin.Unit +public fun com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsScreen(onBackPressed: kotlin.Function0, networkSettingsViewModel: com.wire.android.ui.home.settings.appsettings.networkSettings.NetworkSettingsViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) - networkSettingsViewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -9709,11 +9765,12 @@ public fun com.wire.android.ui.home.settings.backup.BackupAndRestoreContent(back - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.settings.backup.BackupAndRestoreScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.backup.BackupAndRestoreViewModel): kotlin.Unit +public fun com.wire.android.ui.home.settings.backup.BackupAndRestoreScreen(onBackPressed: kotlin.Function0, onOpenConversations: kotlin.Function0, viewModel: com.wire.android.ui.home.settings.backup.BackupAndRestoreViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) + - onOpenConversations: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -9922,16 +9979,18 @@ public fun com.wire.android.ui.home.settings.deleteAccountViewModel(): com.wire. params: @Composable -public fun com.wire.android.ui.home.settings.deviceDetailsViewModel(): com.wire.android.ui.settings.devices.DeviceDetailsViewModel - skippable: true +public fun com.wire.android.ui.home.settings.deviceDetailsViewModel(arguments: com.wire.android.ui.settings.devices.DeviceDetailsViewModelArgs): com.wire.android.ui.settings.devices.DeviceDetailsViewModel + skippable: false restartable: true params: + - arguments: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.home.settings.e2eiCertificateDetailsViewModel(): com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel +public fun com.wire.android.ui.home.settings.e2eiCertificateDetailsViewModel(arguments: com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModelArgs): com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel skippable: true restartable: true params: + - arguments: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.settings.enterLockScreenViewModel(): com.wire.android.ui.home.appLock.unlock.EnterLockScreenViewModel @@ -9958,17 +10017,18 @@ public fun com.wire.android.ui.home.settings.networkSettingsViewModel(): com.wir params: @Composable -public fun com.wire.android.ui.home.settings.otherUserProfileScreenViewModel(): com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel +public fun com.wire.android.ui.home.settings.otherUserProfileScreenViewModel(arguments: com.wire.android.ui.userprofile.other.OtherUserProfileViewModelArgs): com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel skippable: true restartable: true params: + - arguments: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.home.settings.privacy.PrivacySettingsConfigScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.home.settings.privacy.PrivacySettingsViewModel): kotlin.Unit +public fun com.wire.android.ui.home.settings.privacy.PrivacySettingsConfigScreen(onBackPressed: kotlin.Function0, viewModel: com.wire.android.ui.home.settings.privacy.PrivacySettingsViewModel): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -10003,10 +10063,11 @@ public fun com.wire.android.ui.home.settings.selfDevicesViewModel(): com.wire.an params: @Composable -public fun com.wire.android.ui.home.settings.selfQRCodeViewModel(): com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel +public fun com.wire.android.ui.home.settings.selfQRCodeViewModel(arguments: com.wire.android.ui.userprofile.qr.SelfQrCodeViewModelArgs): com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel skippable: true restartable: true params: + - arguments: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.settings.selfUserProfileViewModel(): com.wire.android.ui.userprofile.self.SelfUserProfileViewModel @@ -10015,10 +10076,11 @@ public fun com.wire.android.ui.home.settings.selfUserProfileViewModel(): com.wir params: @Composable -public fun com.wire.android.ui.home.settings.serviceDetailsViewModel(): com.wire.android.ui.userprofile.service.ServiceDetailsViewModel +public fun com.wire.android.ui.home.settings.serviceDetailsViewModel(arguments: com.wire.android.ui.userprofile.service.ServiceDetailsViewModelArgs): com.wire.android.ui.userprofile.service.ServiceDetailsViewModel skippable: true restartable: true params: + - arguments: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.settings.setLockScreenViewModel(): com.wire.android.ui.home.appLock.set.SetLockScreenViewModel @@ -10026,14 +10088,6 @@ public fun com.wire.android.ui.home.settings.setLockScreenViewModel(): com.wire. restartable: true params: -@Composable -public fun com.wire.android.ui.home.settings.settingsSavedStateViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, key: kotlin.String?): VM of com.wire.android.ui.home.settings.settingsSavedStateViewModel - skippable: false - restartable: true - params: - - viewModelStoreOwner: RUNTIME (requires runtime check) - - key: STABLE (class with no mutable properties) - @Composable public fun com.wire.android.ui.home.settings.settingsScreenViewModel(): com.wire.android.ui.home.settings.SettingsViewModel skippable: true @@ -10056,10 +10110,11 @@ public fun com.wire.android.ui.home.settings.teamMigrationViewModel(viewModelSto - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -public fun com.wire.android.ui.home.settings.verifyEmailViewModel(): com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel +public fun com.wire.android.ui.home.settings.verifyEmailViewModel(arguments: com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModelArgs): com.wire.android.ui.home.settings.account.email.verifyEmail.VerifyEmailViewModel skippable: true restartable: true params: + - arguments: STABLE (class with no mutable properties) @Composable public fun com.wire.android.ui.home.vault.VaultScreen(): kotlin.Unit @@ -10082,11 +10137,12 @@ public fun com.wire.android.ui.home.whatsnew.WhatsNewItem(modifier: androidx.com - isLoading: STABLE (primitive type) @Composable -public fun com.wire.android.ui.home.whatsnew.WhatsNewScreen(homeStateHolder: com.wire.android.ui.home.HomeStateHolder, whatsNewViewModel: com.wire.android.ui.home.whatsnew.WhatsNewViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.whatsnew.WhatsNewScreen(homeShellState: com.wire.android.ui.home.HomeShellState, onOpenTarget: kotlin.Function1, whatsNewViewModel: com.wire.android.ui.home.whatsnew.WhatsNewViewModel): kotlin.Unit skippable: false restartable: true params: - - homeStateHolder: UNSTABLE (has mutable properties or unstable members) + - homeShellState: RUNTIME (requires runtime check) + - onOpenTarget: STABLE (function type) - whatsNewViewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -10112,16 +10168,16 @@ public fun com.wire.android.ui.initialSyncViewModel(): com.wire.android.ui.initi params: @Composable -public fun com.wire.android.ui.initialsync.InitialSyncScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.initialsync.InitialSyncViewModel): kotlin.Unit +internal fun com.wire.android.ui.initialsync.InitialSyncRouteScreen(viewModel: com.wire.android.ui.initialsync.InitialSyncViewModel, onSyncCompleted: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onSyncCompleted: STABLE (function type) @Composable -public fun com.wire.android.ui.joinConversation.JoinConversationViaDeepLinkDialog(name: kotlin.String?, code: kotlin.String, key: kotlin.String, domain: kotlin.String?, requirePassword: kotlin.Boolean, onFlowCompleted: kotlin.Function1<@[ParameterName(name = \, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true +public fun com.wire.android.ui.joinConversation.JoinConversationViaDeepLinkDialog(name: kotlin.String?, code: kotlin.String, key: kotlin.String, domain: kotlin.String?, requirePassword: kotlin.Boolean, onFlowCompleted: kotlin.Function1<@[ParameterName(name = \, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel): kotlin.Unit + skippable: false restartable: true params: - name: STABLE (class with no mutable properties) @@ -10131,6 +10187,7 @@ public fun com.wire.android.ui.joinConversation.JoinConversationViaDeepLinkDialo - requirePassword: STABLE (primitive type) - onFlowCompleted: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) + - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable public fun com.wire.android.ui.joinConversation.JoinConversationViaDeepLinkErrorDialog(errorMessage: kotlin.String, onCancel: kotlin.Function0): kotlin.Unit @@ -10149,10 +10206,11 @@ public fun com.wire.android.ui.joinConversation.JoinConversationViaInviteLinkErr - onCancel: STABLE (function type) @Composable -public fun com.wire.android.ui.joinConversationViaCodeViewModel(): com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel - skippable: true +public fun com.wire.android.ui.joinConversationViaCodeViewModel(owner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.joinConversation.JoinConversationViaCodeViewModel + skippable: false restartable: true params: + - owner: RUNTIME (requires runtime check) @Composable public fun com.wire.android.ui.legalHoldRequestedViewModel(): com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel @@ -10418,10 +10476,11 @@ private fun com.wire.android.ui.markdown.getMentions(onOpenProfile: kotlin.Funct - onOpenProfile: STABLE (function type) @Composable -public fun com.wire.android.ui.miscViewModel(): VM of com.wire.android.ui.miscViewModel - skippable: true +public fun com.wire.android.ui.miscViewModel(owner: androidx.lifecycle.ViewModelStoreOwner): VM of com.wire.android.ui.miscViewModel + skippable: false restartable: true params: + - owner: RUNTIME (requires runtime check) @Composable internal fun com.wire.android.ui.newauthentication.login.BackendSelectorDropDown(onNoBackendSelected: kotlin.Function0): kotlin.Unit @@ -10516,13 +10575,15 @@ public fun com.wire.android.ui.newauthentication.login.NewAuthTitle(title: kotli - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.newauthentication.login.NewLoginScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.authentication.login.LoginNavArgs, viewModel: com.wire.android.ui.newauthentication.login.NewLoginViewModel): kotlin.Unit +internal fun com.wire.android.ui.newauthentication.login.NewLoginRouteScreen(navArgs: com.wire.android.ui.authentication.login.LoginNavArgs, viewModel: com.wire.android.ui.newauthentication.login.NewLoginViewModel, canNavigateBack: kotlin.Boolean, navigateBack: kotlin.Function0, onAction: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - navArgs: UNSTABLE (has mutable properties or unstable members) - viewModel: UNSTABLE (has mutable properties or unstable members) + - canNavigateBack: STABLE (primitive type) + - navigateBack: STABLE (function type) + - onAction: STABLE (function type) @Composable private fun com.wire.android.ui.newauthentication.login.code.LoginVerificationCodeContent(codeTextState: androidx.compose.foundation.text.input.TextFieldState, codeState: com.wire.android.ui.authentication.verificationcode.VerificationCodeState, isLoading: kotlin.Boolean, onResendCode: kotlin.Function0, onLoginButtonClick: kotlin.Function0, canNavigateBack: kotlin.Boolean, navigateBack: kotlin.Function0): kotlin.Unit @@ -10538,12 +10599,13 @@ private fun com.wire.android.ui.newauthentication.login.code.LoginVerificationCo - navigateBack: STABLE (function type) @Composable -public fun com.wire.android.ui.newauthentication.login.code.NewLoginVerificationCodeScreen(navigator: com.wire.android.navigation.Navigator, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel): kotlin.Unit +internal fun com.wire.android.ui.newauthentication.login.code.NewLoginVerificationCodeRouteScreen(loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, canNavigateBack: kotlin.Boolean, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - loginEmailViewModel: UNSTABLE (has mutable properties or unstable members) + - canNavigateBack: STABLE (primitive type) + - onNavigateBack: STABLE (function type) @Composable private fun com.wire.android.ui.newauthentication.login.password.CreateAccountContent(onCreateAccountClicked: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -10581,21 +10643,22 @@ internal fun com.wire.android.ui.newauthentication.login.password.LoginPasswordC - isCloudAccountCreationPossible: STABLE (primitive type) @Composable -public fun com.wire.android.ui.newauthentication.login.password.LoginStateNavigationAndDialogs(viewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, navigator: com.wire.android.navigation.Navigator): kotlin.Unit +internal fun com.wire.android.ui.newauthentication.login.password.LoginStateNavigationAndDialogs(viewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, onAction: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - viewModel: UNSTABLE (has mutable properties or unstable members) - - navigator: STABLE (marked @Stable or @Immutable) + - onAction: STABLE (function type) @Composable -public fun com.wire.android.ui.newauthentication.login.password.NewLoginPasswordScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel): kotlin.Unit +internal fun com.wire.android.ui.newauthentication.login.password.NewLoginPasswordRouteScreen(navArgs: com.wire.android.ui.authentication.login.LoginNavArgs, loginEmailViewModel: com.wire.android.ui.authentication.login.email.LoginEmailViewModel, canNavigateBack: kotlin.Boolean, onAction: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - navArgs: UNSTABLE (has mutable properties or unstable members) - loginEmailViewModel: UNSTABLE (has mutable properties or unstable members) + - canNavigateBack: STABLE (primitive type) + - onAction: STABLE (function type) @Composable public fun com.wire.android.ui.newauthentication.login.password.PasswordInput(passwordState: androidx.compose.foundation.text.input.TextFieldState, state: com.wire.android.ui.common.textfield.WireTextFieldState, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -10624,12 +10687,11 @@ public fun com.wire.android.ui.newauthentication.welcome.NewWelcomeEmptyStartScr params: @Composable -public fun com.wire.android.ui.newauthentication.welcome.WelcomeChooserScreen(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector): kotlin.Unit - skippable: false +internal fun com.wire.android.ui.newauthentication.welcome.WelcomeChooserScreen(onChooseLogin: kotlin.Function0): kotlin.Unit + skippable: true restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) + - onChooseLogin: STABLE (function type) @Composable private fun com.wire.android.ui.registration.code.CodeContent(state: com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewState, textState: androidx.compose.foundation.text.input.TextFieldState, onResendCodePressed: kotlin.Function0, onBackPressed: kotlin.Function0, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links): kotlin.Unit @@ -10643,12 +10705,14 @@ private fun com.wire.android.ui.registration.code.CodeContent(state: com.wire.an - serverConfig: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.registration.code.CreateAccountVerificationCodeScreen(navigator: com.wire.android.navigation.Navigator, createAccountCodeVerificationViewModel: com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel): kotlin.Unit +internal fun com.wire.android.ui.registration.code.CreateAccountVerificationCodeRouteScreen(viewModel: com.wire.android.ui.registration.code.CreateAccountVerificationCodeViewModel, onNavigateBack: kotlin.Function0, onSuccess: kotlin.Function1, onTooManyDevices: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - createAccountCodeVerificationViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onSuccess: STABLE (function type) + - onTooManyDevices: STABLE (function type) @Composable private fun com.wire.android.ui.registration.code.getResources(): com.wire.android.util.DialogErrorStrings @@ -10684,12 +10748,13 @@ private fun com.wire.android.ui.registration.details.BackLinkToTeamCreation(team - teamCreationUrl: STABLE (String is immutable) @Composable -public fun com.wire.android.ui.registration.details.CreateAccountDataDetailScreen(navigator: com.wire.android.navigation.Navigator, createAccountDataDetailViewModel: com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel): kotlin.Unit +internal fun com.wire.android.ui.registration.details.CreateAccountDataDetailRouteScreen(viewModel: com.wire.android.ui.registration.details.CreateAccountDataDetailViewModel, onNavigateBack: kotlin.Function0, onCodeRequested: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - createAccountDataDetailViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onCodeRequested: STABLE (function type) @Composable private fun com.wire.android.ui.registration.details.EmailErrorDetailText(error: com.wire.android.ui.registration.details.CreateAccountDataDetailViewState.DetailsError): kotlin.Unit @@ -10736,21 +10801,36 @@ public fun com.wire.android.ui.registration.selector.CreateAccountSelectorConten - onTeamAccountCreationClicked: STABLE (function type) @Composable -public fun com.wire.android.ui.registration.selector.CreateAccountSelectorScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel): kotlin.Unit +internal fun com.wire.android.ui.registration.selector.CreateAccountSelectorRouteScreen(viewModel: com.wire.android.ui.registration.selector.CreateAccountSelectorViewModel, onPersonalAccountCreation: kotlin.Function1, onTeamAccountCreation: kotlin.Function1, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onPersonalAccountCreation: STABLE (function type) + - onTeamAccountCreation: STABLE (function type) + - onNavigateBack: STABLE (function type) @Composable -internal fun com.wire.android.ui.rememberWireActivityNavigator(isUserUiBlocked: kotlin.Boolean, finish: kotlin.Function0, isAllowedToNavigate: kotlin.Function1): com.wire.android.navigation.Navigator - skippable: true +private fun com.wire.android.ui.rememberConfirmedSessionGraphAvailability(confirmedSessionGeneration: kotlin.Long, currentUserId: com.wire.kalium.logic.data.id.QualifiedID?, currentRouteSessionId: com.wire.navigation.WireSessionId?, invalidated: kotlin.Boolean, sessionGraphStore: com.wire.android.navigation.runtime.SessionGraphStoreViewModel): kotlin.Long + skippable: false restartable: true params: - - isUserUiBlocked: STABLE (primitive type) - - finish: STABLE (function type) - - isAllowedToNavigate: STABLE (function type) + - confirmedSessionGeneration: STABLE (primitive type) + - currentUserId: UNSTABLE (has mutable properties or unstable members) + - currentRouteSessionId: STABLE (class with no mutable properties) + - invalidated: STABLE (primitive type) + - sessionGraphStore: UNSTABLE (has mutable properties or unstable members) + +@Composable +private fun com.wire.android.ui.rememberWireActivitySessionLifecycle(routeSessionId: com.wire.navigation.WireSessionId?, graphContext: com.wire.android.navigation.runtime.WireActivityGraphContext?, invalidated: kotlin.Boolean, sessionGraphStore: com.wire.android.navigation.runtime.SessionGraphStoreViewModel, provider: androidx.lifecycle.viewmodel.ViewModelStoreProvider): com.wire.android.ui.WireActivitySessionLifecycle + skippable: false + restartable: true + params: + - routeSessionId: STABLE (class with no mutable properties) + - graphContext: RUNTIME (requires runtime check) + - invalidated: STABLE (primitive type) + - sessionGraphStore: UNSTABLE (has mutable properties or unstable members) + - provider: UNSTABLE (has mutable properties or unstable members) @Composable private fun com.wire.android.ui.server.ApiVersioningDialog(title: kotlin.String, text: kotlin.String, actionText: kotlin.String, onDismiss: kotlin.Function0, onAction: kotlin.Function0, isPreview: kotlin.Boolean): kotlin.Unit @@ -10791,12 +10871,13 @@ private fun com.wire.android.ui.settings.about.AboutThisAppContent(state: com.wi - onItemClicked: STABLE (function type) @Composable -public fun com.wire.android.ui.settings.about.AboutThisAppScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.settings.about.AboutThisAppViewModel): kotlin.Unit +internal fun com.wire.android.ui.settings.about.AboutThisAppRouteScreen(viewModel: com.wire.android.ui.settings.about.AboutThisAppViewModel, onBackPressed: kotlin.Function0, onItemClicked: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onBackPressed: STABLE (function type) + - onItemClicked: STABLE (function type) @Composable public fun com.wire.android.ui.settings.about.rememberAboutThisAppContentState(): com.wire.android.ui.settings.about.AboutThisAppContentState @@ -10852,12 +10933,22 @@ public fun com.wire.android.ui.settings.devices.DeviceDetailsContent(state: com. - onBreakSession: STABLE (function type) @Composable -public fun com.wire.android.ui.settings.devices.DeviceDetailsScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.settings.devices.DeviceDetailsViewModel): kotlin.Unit +private fun com.wire.android.ui.settings.devices.DeviceDetailsNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.settings.devices.DeviceDetailsRoute, actions: com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + +@Composable +internal fun com.wire.android.ui.settings.devices.DeviceDetailsRouteScreen(viewModel: com.wire.android.ui.settings.devices.DeviceDetailsViewModel, onNavigateBack: kotlin.Function0, onOpenCertificateDetails: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onOpenCertificateDetails: STABLE (function type) @Composable private fun com.wire.android.ui.settings.devices.DeviceDetailsTopBar(onNavigateBack: kotlin.Function0, device: com.wire.android.ui.authentication.devices.model.Device, isCurrentDevice: kotlin.Boolean, shouldShowE2EIInfo: kotlin.Boolean): kotlin.Unit @@ -10906,6 +10997,15 @@ public fun com.wire.android.ui.settings.devices.DeviceVerificationItem(state: ko - onStatusChange: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) +@Composable +private fun com.wire.android.ui.settings.devices.E2EIEnrollmentNavigation3Entry(route: com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute, actions: com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) + @Composable private fun com.wire.android.ui.settings.devices.E2EIStatusRow(label: kotlin.String, labelColor: androidx.compose.ui.graphics.Color, icon: kotlin.Int, iconContentDescription: kotlin.String): kotlin.Unit skippable: true @@ -10916,6 +11016,15 @@ private fun com.wire.android.ui.settings.devices.E2EIStatusRow(label: kotlin.Str - icon: STABLE (primitive type) - iconContentDescription: STABLE (String is immutable) +@Composable +private fun com.wire.android.ui.settings.devices.E2eiCertificateDetailsNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsRoute, actions: com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + @Composable public fun com.wire.android.ui.settings.devices.EndToEndIdentityCertificateItem(isE2eiCertificateActivated: kotlin.Boolean, mlsClientIdentity: com.wire.kalium.logic.feature.e2ei.MLSClientIdentity?, isCurrentDevice: kotlin.Boolean, isLoadingCertificate: kotlin.Boolean, enrollE2eiCertificate: kotlin.Function0, showCertificate: kotlin.Function1, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false @@ -10930,12 +11039,40 @@ public fun com.wire.android.ui.settings.devices.EndToEndIdentityCertificateItem( - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.settings.devices.SelfDevicesScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.settings.devices.SelfDevicesViewModel): kotlin.Unit +private fun com.wire.android.ui.settings.devices.RegisterDeviceNavigation3Entry(route: com.wire.android.ui.authentication.devices.register.RegisterDeviceRoute, actions: com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) + +@Composable +private fun com.wire.android.ui.settings.devices.RemoveDeviceNavigation3Entry(route: com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute, actions: com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions, authenticationRouter: com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + - authenticationRouter: UNSTABLE (has mutable properties or unstable members) + +@Composable +private fun com.wire.android.ui.settings.devices.SelfDevicesNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.settings.devices.SelfDevicesRoute, actions: com.wire.android.ui.settings.devices.DeviceE2EINavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + +@Composable +internal fun com.wire.android.ui.settings.devices.SelfDevicesRouteScreen(viewModel: com.wire.android.ui.settings.devices.SelfDevicesViewModel, onNavigateBack: kotlin.Function0, onDeviceClick: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onDeviceClick: STABLE (function type) @Composable public fun com.wire.android.ui.settings.devices.SelfDevicesScreenContent(state: com.wire.android.ui.settings.devices.model.SelfDevicesState, modifier: androidx.compose.ui.Modifier, onNavigateBack: kotlin.Function0, onDeviceClick: kotlin.Function1): kotlin.Unit @@ -11030,12 +11167,12 @@ public fun com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsConte - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsScreen(navigator: com.wire.android.navigation.Navigator, e2eiCertificateDetailsViewModel: com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel): kotlin.Unit +internal fun com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsRouteScreen(viewModel: com.wire.android.ui.settings.devices.e2ei.E2eiCertificateDetailsViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - e2eiCertificateDetailsViewModel: UNSTABLE (has mutable properties or unstable members) + - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable public fun com.wire.android.ui.settings.devices.rememberConversationScreenState(coroutineScope: kotlinx.coroutines.CoroutineScope): com.wire.android.ui.settings.devices.DeviceDetailsScreenState @@ -11063,14 +11200,14 @@ private fun com.wire.android.ui.sharing.ImportAssetsCarrousel(importedItemsList: - onRemoveAsset: STABLE (function type) @Composable -private fun com.wire.android.ui.sharing.ImportMediaAuthenticatedContent(navArgs: com.wire.android.ui.sharing.ImportMediaNavArgs, navigator: com.wire.android.navigation.Navigator, isRestrictedInTeam: kotlin.Boolean, navigateBack: kotlin.Function0, checkAssetRestrictionsViewModel: com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel, importMediaViewModel: com.wire.android.ui.sharing.ImportMediaAuthenticatedViewModel): kotlin.Unit +internal fun com.wire.android.ui.sharing.ImportMediaAuthenticatedContent(isRestrictedInTeam: kotlin.Boolean, navigateBack: kotlin.Function0, onConversationReady: kotlin.Function3, kotlin.String?, kotlin.Unit>, internalAssetUris: kotlin.collections.List, checkAssetRestrictionsViewModel: com.wire.android.ui.home.conversations.media.CheckAssetRestrictionsViewModel, importMediaViewModel: com.wire.android.ui.sharing.ImportMediaAuthenticatedViewModel): kotlin.Unit skippable: false restartable: true params: - - navArgs: UNSTABLE (has mutable properties or unstable members) - - navigator: STABLE (marked @Stable or @Immutable) - isRestrictedInTeam: STABLE (primitive type) - navigateBack: STABLE (function type) + - onConversationReady: STABLE (function type) + - internalAssetUris: RUNTIME (requires runtime check) - checkAssetRestrictionsViewModel: UNSTABLE (has mutable properties or unstable members) - importMediaViewModel: UNSTABLE (has mutable properties or unstable members) @@ -11095,7 +11232,7 @@ private fun com.wire.android.ui.sharing.ImportMediaContent(state: com.wire.andro - lazyListState: STABLE (marked @Stable or @Immutable) @Composable -private fun com.wire.android.ui.sharing.ImportMediaLoadingContent(navigateBack: kotlin.Function0): kotlin.Unit +internal fun com.wire.android.ui.sharing.ImportMediaLoadingContent(navigateBack: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: @@ -11137,16 +11274,6 @@ public fun com.wire.android.ui.sharing.ImportMediaRestrictedContent(importMediaA - navigateBack: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) -@Composable -public fun com.wire.android.ui.sharing.ImportMediaScreen(navArgs: com.wire.android.ui.sharing.ImportMediaNavArgs, navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, featureFlagNotificationViewModel: com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel): kotlin.Unit - skippable: false - restartable: true - params: - - navArgs: UNSTABLE (has mutable properties or unstable members) - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) - - featureFlagNotificationViewModel: UNSTABLE (has mutable properties or unstable members) - @Composable public fun com.wire.android.ui.sharing.ImportMediaTopBarContent(state: com.wire.android.ui.sharing.ImportMediaAuthenticatedState, searchBarState: com.wire.android.ui.common.search.SearchBarState, searchQueryTextState: androidx.compose.foundation.text.input.TextFieldState, onRemoveAsset: kotlin.Function1<@[ParameterName(name = \, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false @@ -11182,6 +11309,49 @@ public fun com.wire.android.ui.sharing.rememberImportMediaScreenState(searchBarS - coroutineScope: RUNTIME (requires runtime check) - bottomSheetState: UNSTABLE (has mutable properties or unstable members) +@Composable +private fun com.wire.android.ui.userprofile.AvatarPickerNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + +@Composable +private fun com.wire.android.ui.userprofile.OtherUserProfileNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.userprofile.other.OtherUserProfileRoute, actions: com.wire.android.ui.userprofile.UserProfileNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.userprofile.SelfQrCodeNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.userprofile.qr.SelfQrCodeRoute, actions: com.wire.android.ui.userprofile.UserProfileNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.userprofile.SelfUserProfileNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.userprofile.self.SelfUserProfileRoute, actions: com.wire.android.ui.userprofile.UserProfileNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.userprofile.ServiceDetailsNavigation3Entry(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, route: com.wire.android.ui.userprofile.service.ServiceDetailsRoute, actions: com.wire.android.ui.userprofile.UserProfileNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - runtime: STABLE (marked @Stable or @Immutable) + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + @Composable private fun com.wire.android.ui.userprofile.avatarpicker.AvatarPickerActionButtons(pictureState: com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel.PictureState, onSaveClick: kotlin.Function0, onCancelClick: kotlin.Function0, onChangeImage: kotlin.Function0): kotlin.Unit skippable: true @@ -11204,13 +11374,13 @@ private fun com.wire.android.ui.userprofile.avatarpicker.AvatarPickerContent(pic - onSaveClick: STABLE (function type) @Composable -public fun com.wire.android.ui.userprofile.avatarpicker.AvatarPickerScreen(navigator: com.wire.android.navigation.Navigator, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, viewModel: com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.avatarpicker.AvatarPickerScreen(viewModel: com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel, onNavigateBack: kotlin.Function0, onAvatarSelected: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - resultNavigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onAvatarSelected: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.avatarpicker.AvatarPickerTopBar(onCloseClick: kotlin.Function0): kotlin.Unit @@ -11433,15 +11603,19 @@ public fun com.wire.android.ui.userprofile.other.OtherUserProfileGroup(state: co - lazyListState: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.userprofile.other.OtherUserProfileScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.userprofile.other.OtherUserProfileNavArgs, resultNavigator: com.ramcosta.composedestinations.result.ResultBackNavigator, conversationFoldersScreenResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, viewModel: com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.other.OtherUserProfileRouteScreen(viewModel: com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel, onNavigateBack: kotlin.Function0, onIgnoredConnectionRequest: kotlin.Function1, onOpenConversation: kotlin.Function1, onOpenDeviceDetails: kotlin.Function1, onSearchConversationMessages: kotlin.Function1, onOpenConversationMedia: kotlin.Function1, onMoveToFolder: kotlin.Function2, kotlin.Unit>, onOpenConversationDebugMenu: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - navArgs: UNSTABLE (has mutable properties or unstable members) - - resultNavigator: STABLE (marked @Stable or @Immutable) - - conversationFoldersScreenResultRecipient: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) + - onIgnoredConnectionRequest: STABLE (function type) + - onOpenConversation: STABLE (function type) + - onOpenDeviceDetails: STABLE (function type) + - onSearchConversationMessages: STABLE (function type) + - onOpenConversationMedia: STABLE (function type) + - onMoveToFolder: STABLE (function type) + - onOpenConversationDebugMenu: STABLE (function type) @Composable internal fun com.wire.android.ui.userprofile.other.RemoveConversationMemberDialog(dialogState: com.wire.android.ui.common.visbility.VisibilityState, onRemoveConversationMember: kotlin.Function1): kotlin.Unit @@ -11562,12 +11736,12 @@ private fun com.wire.android.ui.userprofile.qr.SelfQRCodeContent(state: com.wire - onBackClick: STABLE (function type) @Composable -public fun com.wire.android.ui.userprofile.qr.SelfQRCodeScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.qr.SelfQRCodeScreen(viewModel: com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel, onNavigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - onNavigateBack: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.qr.ShareLinkButton(isTeamMember: kotlin.Boolean, selfProfileUrl: kotlin.String, trackAnalyticsEvent: kotlin.Function1): kotlin.Unit @@ -11670,15 +11844,21 @@ private fun com.wire.android.ui.userprofile.self.SelfUserProfileContent(state: c - isUserInCall: STABLE (function type) @Composable -public fun com.wire.android.ui.userprofile.self.SelfUserProfileScreen(navigator: com.wire.android.navigation.Navigator, loginTypeSelector: com.wire.android.navigation.LoginTypeSelector, avatarPickerResultRecipient: com.ramcosta.composedestinations.result.ResultRecipient, viewModelSelf: com.wire.android.ui.userprofile.self.SelfUserProfileViewModel, legalHoldRequestedViewModel: com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.self.SelfUserProfileRouteScreen(viewModelSelf: com.wire.android.ui.userprofile.self.SelfUserProfileViewModel, legalHoldRequestedViewModel: com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel, onClose: kotlin.Function0, onLogout: kotlin.Function1, onChangeAvatar: kotlin.Function0, onEditProfile: kotlin.Function0, onAddAccount: kotlin.Function0, onSwitchAccount: kotlin.Function1, onOpenQrCode: kotlin.Function2<@[ParameterName(name = \, onCreateTeam: kotlin.Function1<@[ParameterName(name = \, onOpenAccountDetails: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - loginTypeSelector: RUNTIME (requires runtime check) - - avatarPickerResultRecipient: STABLE (marked @Stable or @Immutable) - viewModelSelf: UNSTABLE (has mutable properties or unstable members) - legalHoldRequestedViewModel: UNSTABLE (has mutable properties or unstable members) + - onClose: STABLE (function type) + - onLogout: STABLE (function type) + - onChangeAvatar: STABLE (function type) + - onEditProfile: STABLE (function type) + - onAddAccount: STABLE (function type) + - onSwitchAccount: STABLE (function type) + - onOpenQrCode: STABLE (function type) + - onCreateTeam: STABLE (function type) + - onOpenAccountDetails: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.self.SelfUserProfileTopBar(onCloseClick: kotlin.Function0, onLogoutClick: kotlin.Function0): kotlin.Unit @@ -11778,12 +11958,13 @@ private fun com.wire.android.ui.userprofile.service.ServiceDetailsProfileInfo(st - state: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.ui.userprofile.service.ServiceDetailsScreen(navigator: com.wire.android.navigation.Navigator, viewModel: com.wire.android.ui.userprofile.service.ServiceDetailsViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.service.ServiceDetailsScreen(viewModel: com.wire.android.ui.userprofile.service.ServiceDetailsViewModel, navigateBack: kotlin.Function0, openConversation: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - viewModel: RUNTIME (requires runtime check) + - navigateBack: STABLE (function type) + - openConversation: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.service.ServiceDetailsStartOrOpenConversation(isDataLoading: kotlin.Boolean, isActionLoading: kotlin.Boolean, isConversationStarted: kotlin.Boolean, onOpenConversation: kotlin.Function0): kotlin.Unit @@ -11802,6 +11983,39 @@ private fun com.wire.android.ui.userprofile.service.ServiceDetailsTopAppBar(onBa params: - onBackPressed: STABLE (function type) +@Composable +private fun com.wire.android.ui.userprofile.teammigration.TeamMigrationConfirmationNavigation3Entry(route: com.wire.android.ui.userprofile.teammigration.TeamMigrationConfirmationRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + +@Composable +private fun com.wire.android.ui.userprofile.teammigration.TeamMigrationDoneNavigation3Entry(route: com.wire.android.ui.userprofile.teammigration.TeamMigrationDoneRoute, actions: com.wire.android.ui.userprofile.teammigration.TeamMigrationNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - actions: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.ui.userprofile.teammigration.TeamMigrationTeamNameNavigation3Entry(route: com.wire.android.ui.userprofile.teammigration.TeamMigrationTeamNameRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime): kotlin.Unit + skippable: true + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + +@Composable +private fun com.wire.android.ui.userprofile.teammigration.TeamMigrationTeamPlanNavigation3Entry(route: com.wire.android.ui.userprofile.teammigration.TeamMigrationTeamPlanRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.userprofile.teammigration.TeamMigrationNavigation3Actions): kotlin.Unit + skippable: false + restartable: true + params: + - route: STABLE (class with no mutable properties) + - runtime: STABLE (marked @Stable or @Immutable) + - actions: RUNTIME (requires runtime check) + @Composable public fun com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons(isContinueButtonEnabled: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, isMigrating: kotlin.Boolean, isBackButtonVisible: kotlin.Boolean, backButtonContentDescription: kotlin.String, backButtonText: kotlin.String, onBack: kotlin.Function0, continueButtonText: kotlin.String, onContinue: kotlin.Function0): kotlin.Unit skippable: true @@ -11873,13 +12087,14 @@ private fun com.wire.android.ui.userprofile.teammigration.step1.LearnMoreWirePla - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanStepScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanNavArgs, teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanRouteScreen(teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel, isMigrationDotActive: kotlin.Boolean, onBackButtonClicked: kotlin.Function0, onContinueButtonClicked: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - navArgs: STABLE (class with no mutable properties) - teamMigrationViewModel: UNSTABLE (has mutable properties or unstable members) + - isMigrationDotActive: STABLE (primitive type) + - onBackButtonClicked: STABLE (function type) + - onContinueButtonClicked: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTeamPlanStepScreenContent(modifier: androidx.compose.ui.Modifier, onContinueButtonClicked: kotlin.Function0, onBackButtonClicked: kotlin.Function0): kotlin.Unit @@ -11891,12 +12106,13 @@ private fun com.wire.android.ui.userprofile.teammigration.step1.TeamMigrationTea - onBackButtonClicked: STABLE (function type) @Composable -public fun com.wire.android.ui.userprofile.teammigration.step2.TeamMigrationTeamNameStepScreen(navigator: com.wire.android.navigation.Navigator, teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.teammigration.step2.TeamMigrationTeamNameRouteScreen(teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel, onContinueButtonClicked: kotlin.Function0, onBackButtonClicked: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - teamMigrationViewModel: UNSTABLE (has mutable properties or unstable members) + - onContinueButtonClicked: STABLE (function type) + - onBackButtonClicked: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.teammigration.step2.TeamMigrationTeamNameStepScreenContent(teamNameTextFieldState: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, onContinueButtonClicked: kotlin.Function0, onBackButtonClicked: kotlin.Function0): kotlin.Unit @@ -11936,12 +12152,14 @@ private fun com.wire.android.ui.userprofile.teammigration.step3.HandleErrors(tea - goBackToFirstStep: STABLE (function type) @Composable -public fun com.wire.android.ui.userprofile.teammigration.step3.TeamMigrationConfirmationStepScreen(navigator: com.wire.android.navigation.Navigator, teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel): kotlin.Unit +internal fun com.wire.android.ui.userprofile.teammigration.step3.TeamMigrationConfirmationRouteScreen(teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel, onMigrationCompleted: kotlin.Function0, onBackButtonClicked: kotlin.Function0, onReturnToFirstStep: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - teamMigrationViewModel: UNSTABLE (has mutable properties or unstable members) + - onMigrationCompleted: STABLE (function type) + - onBackButtonClicked: STABLE (function type) + - onReturnToFirstStep: STABLE (function type) @Composable private fun com.wire.android.ui.userprofile.teammigration.step3.TeamMigrationConfirmationStepScreenContent(modifier: androidx.compose.ui.Modifier, isMigrating: kotlin.Boolean, onContinueButtonClicked: kotlin.Function0, onBackButtonClicked: kotlin.Function0): kotlin.Unit @@ -11959,6 +12177,14 @@ private fun com.wire.android.ui.userprofile.teammigration.step3.WireTermsOfUseWi restartable: true params: +@Composable +internal fun com.wire.android.ui.userprofile.teammigration.step4.TeamMigrationDoneRouteScreen(teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel, onBackToWireClicked: kotlin.Function0): kotlin.Unit + skippable: false + restartable: true + params: + - teamMigrationViewModel: UNSTABLE (has mutable properties or unstable members) + - onBackToWireClicked: STABLE (function type) + @Composable private fun com.wire.android.ui.userprofile.teammigration.step4.TeamMigrationDoneStepContent(onBackToWireClicked: kotlin.Function0, onOpenTeamManagementClicked: kotlin.Function0, username: kotlin.String, teamName: kotlin.String, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: true @@ -11971,19 +12197,18 @@ private fun com.wire.android.ui.userprofile.teammigration.step4.TeamMigrationDon - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.userprofile.teammigration.step4.TeamMigrationDoneStepScreen(navigator: com.wire.android.navigation.Navigator, teamMigrationViewModel: com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel): kotlin.Unit - skippable: false +private fun com.wire.android.ui.userprofile.teammigration.teamMigrationFlowViewModel(flowId: kotlin.String): com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel + skippable: true restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) - - teamMigrationViewModel: UNSTABLE (has mutable properties or unstable members) + - flowId: STABLE (String is immutable) @Composable -internal fun com.wire.android.ui.wireActivityCurrentBackStackEntryAsState(navigator: com.wire.android.navigation.Navigator): androidx.compose.runtime.State - skippable: true +internal fun com.wire.android.ui.wireActivityScopedViewModels(owner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.ui.WireActivityScopedViewModels + skippable: false restartable: true params: - - navigator: STABLE (marked @Stable or @Immutable) + - owner: RUNTIME (requires runtime check) @Composable public fun com.wire.android.util.lifecycle.rememberLifecycleEvent(lifecycleOwner: androidx.lifecycle.LifecycleOwner): androidx.lifecycle.Lifecycle.Event diff --git a/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt b/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt index 4aaeac9b177..7747cb92ff9 100644 --- a/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt +++ b/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt @@ -104,9 +104,10 @@ internal fun defaultViewModelCreationExtras(): CreationExtras { internal inline fun PreviewProvider.findPreviewOr(provideViewModel: @Composable () -> S): S { val errorMessage = "No preview found for ${S::class.qualifiedName} in preview provider ${this::class.qualifiedName}." + " Make sure to add @ViewModelScopedPreview to the ViewModel interface." + val preview = previews.firstNotNullOfOrNull { it as? S } return when { - LocalInspectionMode.current -> previews.firstNotNullOfOrNull { it as? S } ?: error(errorMessage) - espresso -> previews.firstNotNullOfOrNull { it as? S } ?: error(errorMessage) + LocalInspectionMode.current -> preview ?: error(errorMessage) + espresso -> preview ?: provideViewModel() else -> provideViewModel() } } diff --git a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt index 98042806b8f..4778aaf64ed 100644 --- a/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt +++ b/core/navigation/src/main/kotlin/com/wire/android/navigation/navigation3/WireNavigation3Runtime.kt @@ -112,7 +112,7 @@ class WireNavigation3Runtime internal constructor( if (current?.entryId != targetEntryId || backStack.size <= 1) { false } else { - backStack.removeLast() + backStack.removeAt(backStack.lastIndex) true } }, diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt index d8ced9c0459..b709573f29d 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt @@ -26,6 +26,12 @@ import com.wire.android.model.Contact import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.CurrentTimeProvider import com.wire.kalium.logic.data.user.ConnectionState +import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase +import com.wire.kalium.logic.feature.meeting.CreateNewMeetingUseCase +import com.wire.kalium.logic.feature.meeting.GetNextMeetingOccurrenceUseCase +import com.wire.kalium.logic.feature.meeting.UpdateMeetingUseCase +import io.mockk.coEvery +import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -213,7 +219,7 @@ class NewMeetingViewModelTest { enterTitle(viewModel, "Weekly sync") viewModel.actions.test { - viewModel.createMeeting() + viewModel.submitCreation() advanceUntilIdle() assertEquals(NewMeetingViewActions.Success, awaitItem()) @@ -225,7 +231,7 @@ class NewMeetingViewModelTest { val (_, viewModel) = arrangeViewModel() viewModel.actions.test { - viewModel.createMeeting() + viewModel.submitCreation() advanceUntilIdle() expectNoEvents() @@ -270,6 +276,13 @@ class NewMeetingViewModelTest { fun arrange() = this to NewMeetingViewModelImpl( navArgs = NewMeetingNavArgs(type = newMeetingType), currentTimeProvider = currentTimeProvider, + createNewMeeting = mockk().also { + coEvery { it(any()) } returns CreateNewMeetingUseCase.Result.Success + }, + updateMeeting = mockk(relaxed = true), + getNextMeetingOccurrence = mockk(relaxed = true), + observeConversationMembers = mockk(relaxed = true), + contactMapper = mockk(relaxed = true), ) } } From 90645938a730e77d70bfc9b5e1480c52514e3998 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 18 Aug 2026 10:11:32 +0200 Subject: [PATCH 03/13] fix: satisfy Cells Compose lint [MOB-20] --- .../feature/cells/navigation/CellsNavigation3Renderer.kt | 8 ++++---- .../feature/cells/ui/create/file/CreateFileScreen.kt | 2 +- .../feature/cells/ui/create/folder/CreateFolderScreen.kt | 2 +- .../feature/cells/ui/movetofolder/MoveToFolderScreen.kt | 2 +- .../feature/cells/ui/publiclink/PublicLinkScreen.kt | 2 +- .../settings/expiration/PublicLinkExpirationScreen.kt | 2 +- .../settings/password/PublicLinkPasswordScreen.kt | 2 +- .../feature/cells/ui/recyclebin/RecycleBinScreen.kt | 2 +- .../android/feature/cells/ui/rename/RenameNodeScreen.kt | 2 +- .../wire/android/feature/cells/ui/search/SearchScreen.kt | 2 +- .../android/feature/cells/ui/tags/AddRemoveTagsScreen.kt | 2 +- .../feature/cells/ui/versioning/VersionHistoryScreen.kt | 2 +- 12 files changed, 15 insertions(+), 15 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt index 595f57b38ce..fdf5e5c3d33 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt @@ -119,7 +119,7 @@ internal fun CellsNavigation3RouteScreen( ) is MoveToFolderRoute -> { val viewModel = moveToFolderViewModel(route.toScreenArgs()) - consumeNavigation3Result( + ConsumeNavigation3Result( runtime = runtime, requestIdValue = booleanRequestIdValue, resultType = CellsBooleanNavigation3ResultType, @@ -162,14 +162,14 @@ internal fun CellsNavigation3RouteScreen( } is PublicLinkRoute -> { val viewModel = publicLinkViewModel(route.toScreenArgs()) - consumeNavigation3Result( + ConsumeNavigation3Result( runtime = runtime, requestIdValue = booleanRequestIdValue, resultType = CellsBooleanNavigation3ResultType, onConsumed = { booleanRequestIdValue = null }, onResult = { viewModel.onPasswordUpdate(it.value) }, ) - consumeNavigation3Result( + ConsumeNavigation3Result( runtime = runtime, requestIdValue = expirationRequestIdValue, resultType = PublicLinkExpirationNavigation3ResultType, @@ -387,7 +387,7 @@ private fun completeBooleanResult(runtime: WireNavigation3Runtime, value: Boolea } @Composable -private fun consumeNavigation3Result( +private fun ConsumeNavigation3Result( runtime: WireNavigation3Runtime, requestIdValue: String?, resultType: WireNavigation3ResultType, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt index 5f45a6008a8..58b052d07b4 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt @@ -53,8 +53,8 @@ import com.wire.android.ui.theme.wireDimensions internal fun CreateFileRouteScreen( onNavigateBack: () -> Unit, onCreated: () -> Unit, - modifier: Modifier = Modifier, createFileViewModel: CreateFileViewModel, + modifier: Modifier = Modifier, ) { val showErrorDialog = remember { mutableStateOf(false) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt index 14af43e7a0a..44be62cf0fe 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt @@ -58,8 +58,8 @@ import androidx.compose.ui.platform.LocalLocale internal fun CreateFolderRouteScreen( onNavigateBack: () -> Unit, onCreated: () -> Unit, - modifier: Modifier = Modifier, createFolderViewModel: CreateFolderViewModel, + modifier: Modifier = Modifier, ) { val showErrorDialog = remember { mutableStateOf(false) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt index 8408e13c4a9..b3e8faf9c0e 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/movetofolder/MoveToFolderScreen.kt @@ -68,8 +68,8 @@ internal fun MoveToFolderRouteScreen( onNavigateBackSteps: (Int) -> Unit, onOpenCreateFolder: (String) -> Unit, onOpenFolder: (String, String, String, List) -> Unit, - modifier: Modifier = Modifier, moveToFolderViewModel: MoveToFolderViewModel, + modifier: Modifier = Modifier, ) { val context = LocalContext.current val viewState by moveToFolderViewModel.state.collectAsState() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt index 555f5a8631b..6d401966d08 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/PublicLinkScreen.kt @@ -66,8 +66,8 @@ internal fun PublicLinkRouteScreen( onNavigateBack: () -> Unit, onOpenPasswordSettings: (String, Boolean) -> Unit, onOpenExpirationSettings: (String, Long?) -> Unit, - modifier: Modifier = Modifier, viewModel: PublicLinkViewModel, + modifier: Modifier = Modifier, ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt index 7e3cc6248cf..75bcdb34cf1 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/expiration/PublicLinkExpirationScreen.kt @@ -63,8 +63,8 @@ import kotlinx.parcelize.Parcelize @Composable internal fun PublicLinkExpirationRouteScreen( onResult: (PublicLinkExpirationResult) -> Unit, - modifier: Modifier = Modifier, viewModel: PublicLinkExpirationScreenViewModel, + modifier: Modifier = Modifier, ) { val state by viewModel.state.collectAsState() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt index 0ff524cf32a..652d1211a9a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/publiclink/settings/password/PublicLinkPasswordScreen.kt @@ -62,8 +62,8 @@ import com.wire.android.ui.theme.WireTheme @Composable internal fun PublicLinkPasswordRouteScreen( onResult: (Boolean) -> Unit, - modifier: Modifier = Modifier, viewModel: PublicLinkPasswordScreenViewModel, + modifier: Modifier = Modifier, ) { val state by viewModel.state.collectAsState() val clipboardManager = LocalClipboardManager.current diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index 6a68cd39a3e..9e485bfa8c0 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -44,8 +44,8 @@ import com.wire.android.ui.theme.wireTypography @Composable internal fun RecycleBinRouteScreen( navigation: CellsFilesNavigation, - modifier: Modifier = Modifier, cellViewModel: CellViewModel, + modifier: Modifier = Modifier, ) { Box(modifier = modifier) { WireScaffold( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt index 7bb174adb79..8cb563d2595 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt @@ -56,8 +56,8 @@ import com.wire.android.ui.theme.wireDimensions @Composable internal fun RenameNodeRouteScreen( onNavigateBack: () -> Unit, - modifier: Modifier = Modifier, renameNodeViewModel: RenameNodeViewModel, + modifier: Modifier = Modifier, ) { val context = LocalContext.current diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index da3846a2370..0c2214cd152 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -66,8 +66,8 @@ internal fun SearchRouteScreen( navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, animatedVisibilityScope: AnimatedVisibilityScope, cellViewModel: CellViewModel, - modifier: Modifier = Modifier, searchScreenViewModel: SearchScreenViewModel, + modifier: Modifier = Modifier, ) { val uiState by searchScreenViewModel.uiState.collectAsStateWithLifecycle() val isOnlineState by cellViewModel.isOnline.collectAsState() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt index 73c7437b559..d4951aea526 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt @@ -70,8 +70,8 @@ import kotlinx.coroutines.launch @Composable internal fun AddRemoveTagsRouteScreen( onNavigateBack: () -> Unit, - modifier: Modifier = Modifier, addRemoveTagsViewModel: AddRemoveTagsViewModel, + modifier: Modifier = Modifier, ) { val context = LocalContext.current diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt index 904e3fe047f..98561f15437 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt @@ -64,8 +64,8 @@ import kotlinx.coroutines.launch @Composable internal fun VersionHistoryRouteScreen( onNavigateBack: () -> Unit, - modifier: Modifier = Modifier, versionHistoryViewModel: VersionHistoryViewModel, + modifier: Modifier = Modifier, ) { val optionsBottomSheetState = rememberWireModalSheetState>() val snackbarHostState = LocalSnackbarHostState.current From df57b2adbd48275ee3d6f46c7a86967ad08ee77e Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 18 Aug 2026 10:31:03 +0200 Subject: [PATCH 04/13] chore: update Cells stability baseline [MOB-20] --- .../cells/stability/cells-debug.stability | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/features/cells/stability/cells-debug.stability b/features/cells/stability/cells-debug.stability index d59997ca858..0a10f78086a 100644 --- a/features/cells/stability/cells-debug.stability +++ b/features/cells/stability/cells-debug.stability @@ -14,7 +14,7 @@ internal fun com.wire.android.feature.cells.navigation.CellsNavigation3RouteScre - onExitCells: STABLE (function type) @Composable -private fun com.wire.android.feature.cells.navigation.consumeNavigation3Result(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, requestIdValue: kotlin.String?, resultType: com.wire.android.navigation.navigation3.WireNavigation3ResultType, onConsumed: kotlin.Function0, onResult: kotlin.Function1): kotlin.Unit +private fun com.wire.android.feature.cells.navigation.ConsumeNavigation3Result(runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, requestIdValue: kotlin.String?, resultType: com.wire.android.navigation.navigation3.WireNavigation3ResultType, onConsumed: kotlin.Function0, onResult: kotlin.Function1): kotlin.Unit skippable: true restartable: true params: @@ -384,14 +384,14 @@ public fun com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog(sh - onItemSelected: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.create.file.CreateFileRouteScreen(onNavigateBack: kotlin.Function0, onCreated: kotlin.Function0, modifier: androidx.compose.ui.Modifier, createFileViewModel: com.wire.android.feature.cells.ui.create.file.CreateFileViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.create.file.CreateFileRouteScreen(onNavigateBack: kotlin.Function0, onCreated: kotlin.Function0, createFileViewModel: com.wire.android.feature.cells.ui.create.file.CreateFileViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onNavigateBack: STABLE (function type) - onCreated: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - createFileViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.cells.ui.create.file.computeNameErrorState(error: com.wire.android.feature.cells.ui.common.FileNameError?): com.wire.android.ui.common.textfield.WireTextFieldState @@ -401,14 +401,14 @@ private fun com.wire.android.feature.cells.ui.create.file.computeNameErrorState( - error: STABLE (class with no mutable properties) @Composable -internal fun com.wire.android.feature.cells.ui.create.folder.CreateFolderRouteScreen(onNavigateBack: kotlin.Function0, onCreated: kotlin.Function0, modifier: androidx.compose.ui.Modifier, createFolderViewModel: com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.create.folder.CreateFolderRouteScreen(onNavigateBack: kotlin.Function0, onCreated: kotlin.Function0, createFolderViewModel: com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onNavigateBack: STABLE (function type) - onCreated: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - createFolderViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.cells.ui.create.folder.computeNameErrorState(error: com.wire.android.feature.cells.ui.common.FileNameError?): com.wire.android.ui.common.textfield.WireTextFieldState @@ -571,7 +571,7 @@ private fun com.wire.android.feature.cells.ui.movetofolder.EmptyScreen(isRootFol - isRootFolder: STABLE (primitive type) @Composable -internal fun com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScreen(onNavigateBack: kotlin.Function0, onCloseFlow: kotlin.Function0, onNavigateBackSteps: kotlin.Function1, onOpenCreateFolder: kotlin.Function1, onOpenFolder: kotlin.Function4, kotlin.Unit>, modifier: androidx.compose.ui.Modifier, moveToFolderViewModel: com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScreen(onNavigateBack: kotlin.Function0, onCloseFlow: kotlin.Function0, onNavigateBackSteps: kotlin.Function1, onOpenCreateFolder: kotlin.Function1, onOpenFolder: kotlin.Function4, kotlin.Unit>, moveToFolderViewModel: com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: @@ -580,8 +580,8 @@ internal fun com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScr - onNavigateBackSteps: STABLE (function type) - onOpenCreateFolder: STABLE (function type) - onOpenFolder: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - moveToFolderViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable internal fun com.wire.android.feature.cells.ui.movetofolder.MoveToFolderScreenContent(folders: kotlin.collections.List, onFolderClick: kotlin.Function1): kotlin.Unit @@ -639,15 +639,15 @@ private fun com.wire.android.feature.cells.ui.publiclink.EnableLinkSection(check - onCheckClick: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.publiclink.PublicLinkRouteScreen(onNavigateBack: kotlin.Function0, onOpenPasswordSettings: kotlin.Function2, onOpenExpirationSettings: kotlin.Function2, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.publiclink.PublicLinkRouteScreen(onNavigateBack: kotlin.Function0, onOpenPasswordSettings: kotlin.Function2, onOpenExpirationSettings: kotlin.Function2, viewModel: com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onNavigateBack: STABLE (function type) - onOpenPasswordSettings: STABLE (function type) - onOpenExpirationSettings: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable internal fun com.wire.android.feature.cells.ui.publiclink.PublicLinkSection(state: com.wire.android.feature.cells.ui.publiclink.PublicLinkState, expirationError: kotlin.String?, onShareLink: kotlin.Function0, onCopyLink: kotlin.Function0): kotlin.Unit @@ -749,13 +749,13 @@ private fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.Exp - onSetClick: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationRouteScreen(onResult: kotlin.Function1, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationRouteScreen(onResult: kotlin.Function1, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onResult: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.cells.ui.publiclink.settings.expiration.TimeView(time: kotlin.String?, onClick: kotlin.Function0): kotlin.Unit @@ -840,22 +840,22 @@ public fun com.wire.android.feature.cells.ui.publiclink.settings.password.Passwo - onSetPassword: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordRouteScreen(onResult: kotlin.Function1, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordRouteScreen(onResult: kotlin.Function1, viewModel: com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordScreenViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onResult: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.wire.android.feature.cells.ui.recyclebin.RecycleBinRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, modifier: androidx.compose.ui.Modifier, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.recyclebin.RecycleBinRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - navigation: RUNTIME (requires runtime check) - - modifier: STABLE (marked @Stable or @Immutable) - cellViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.feature.cells.ui.recyclebin.RestoreConfirmationDialog(itemName: kotlin.String, isFolder: kotlin.Boolean, isRestoreInProgress: kotlin.Boolean, onConfirm: kotlin.Function0, onDismiss: kotlin.Function0): kotlin.Unit @@ -887,13 +887,13 @@ public fun com.wire.android.feature.cells.ui.recyclebin.UnableToRestoreDialog(is - onDismiss: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.rename.RenameNodeRouteScreen(onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, renameNodeViewModel: com.wire.android.feature.cells.ui.rename.RenameNodeViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.rename.RenameNodeRouteScreen(onNavigateBack: kotlin.Function0, renameNodeViewModel: com.wire.android.feature.cells.ui.rename.RenameNodeViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onNavigateBack: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - renameNodeViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.cells.ui.rename.computeNameErrorState(error: com.wire.android.feature.cells.ui.common.FileNameError?, isFolder: kotlin.Boolean): com.wire.android.ui.common.textfield.WireTextFieldState @@ -911,15 +911,15 @@ internal fun com.wire.android.feature.cells.ui.renameNodeViewModel(navArgs: com. - navArgs: STABLE (class with no mutable properties) @Composable -internal fun com.wire.android.feature.cells.ui.search.SearchRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel, modifier: androidx.compose.ui.Modifier, searchScreenViewModel: com.wire.android.feature.cells.ui.search.SearchScreenViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.search.SearchRouteScreen(navigation: com.wire.android.feature.cells.ui.CellsFilesNavigation, animatedVisibilityScope: androidx.compose.animation.AnimatedVisibilityScope, cellViewModel: com.wire.android.feature.cells.ui.CellViewModel, searchScreenViewModel: com.wire.android.feature.cells.ui.search.SearchScreenViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - navigation: RUNTIME (requires runtime check) - animatedVisibilityScope: RUNTIME (requires runtime check) - cellViewModel: UNSTABLE (has mutable properties or unstable members) - - modifier: STABLE (marked @Stable or @Immutable) - searchScreenViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.feature.cells.ui.search.filter.FilterChipsRow(state: com.wire.android.feature.cells.ui.search.filter.FilterChipsUiState, screenType: com.wire.android.feature.cells.ui.search.DriveSearchScreenType, modifier: androidx.compose.ui.Modifier, onFilterByTagsClicked: kotlin.Function0, onFilterByTypeClicked: kotlin.Function0, onFilterByOwnerClicked: kotlin.Function0, onRemoveAllFiltersClicked: kotlin.Function0, onFilterByConversationClicked: kotlin.Function0, onFilterBySharedByLinkClicked: kotlin.Function0): kotlin.Unit @@ -1113,13 +1113,13 @@ private fun com.wire.android.feature.cells.ui.subtitle(showConversationName: kot - showConversationName: STABLE (primitive type) @Composable -internal fun com.wire.android.feature.cells.ui.tags.AddRemoveTagsRouteScreen(onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, addRemoveTagsViewModel: com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.tags.AddRemoveTagsRouteScreen(onNavigateBack: kotlin.Function0, addRemoveTagsViewModel: com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onNavigateBack: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - addRemoveTagsViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.feature.cells.ui.tags.AddRemoveTagsScreenContent(internalPadding: androidx.compose.foundation.layout.PaddingValues, textFieldState: androidx.compose.foundation.text.input.TextFieldState, addedTags: kotlin.collections.Set, isValidTag: kotlin.Function0, onAddTag: kotlin.Function1, onRemoveTag: kotlin.Function1, onRemoveLastTag: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -1184,13 +1184,13 @@ public fun com.wire.android.feature.cells.ui.versioning.VersionActionBottomSheet - onDownloadVersionClicked: STABLE (function type) @Composable -internal fun com.wire.android.feature.cells.ui.versioning.VersionHistoryRouteScreen(onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier, versionHistoryViewModel: com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.versioning.VersionHistoryRouteScreen(onNavigateBack: kotlin.Function0, versionHistoryViewModel: com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit skippable: false restartable: true params: - onNavigateBack: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - versionHistoryViewModel: UNSTABLE (has mutable properties or unstable members) + - modifier: STABLE (marked @Stable or @Immutable) @Composable private fun com.wire.android.feature.cells.ui.versioning.VersionHistoryScreenContent(versionsGroupedByTime: kotlin.collections.List, versionHistoryState: androidx.compose.runtime.State, optionsBottomSheetState: com.wire.android.ui.common.bottomsheet.WireModalSheetState>, restoreDialogState: com.wire.android.feature.cells.ui.versioning.restore.RestoreDialogState, onRefresh: kotlin.Function0, modifier: androidx.compose.ui.Modifier, fileName: kotlin.String?, restoreVersion: kotlin.Function0, downloadVersion: kotlin.Function2, showRestoreConfirmationDialog: kotlin.Function1, onDismissRestoreConfirmationDialog: kotlin.Function0, onGoToFileClicked: kotlin.Function0, navigateBack: kotlin.Function0): kotlin.Unit From a649a3b97cae001236730b97c49308f6fb5f8f58 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 18 Aug 2026 10:47:03 +0200 Subject: [PATCH 05/13] test: update Navigation 3 route count [MOB-20] --- .../android/navigation/runtime/WireNavigation3Contributions.kt | 2 +- .../navigation/runtime/WireNavigation3ContributionsTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt index 87b0168a02b..25e94144d14 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt @@ -95,7 +95,7 @@ internal data class WireNavigation3ContributionCatalog( * app-lock overlays finish the registry. An entry type must be owned by exactly one contribution. */ internal object WireNavigation3Contributions { - const val EXPECTED_ROUTE_REGISTRATION_COUNT: Int = 106 + const val EXPECTED_ROUTE_REGISTRATION_COUNT: Int = 107 const val EXPECTED_INSTALLER_COUNT: Int = 19 fun create( diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt index c8350ce70c3..584a365b209 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt @@ -90,7 +90,7 @@ class WireNavigation3ContributionsTest { } assertEquals(WireNavigation3Contributions.EXPECTED_ROUTE_REGISTRATION_COUNT, registrationCount) - assertEquals(106, registrationCount) + assertEquals(107, registrationCount) } @Test From e01bd833db50203af9c4059fe06b51ec296c4f38 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 18 Aug 2026 14:16:31 +0200 Subject: [PATCH 06/13] ci: build Alpha artifacts for navigation testing [MOB-20] --- .github/workflows/build-develop-pr.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-develop-pr.yml b/.github/workflows/build-develop-pr.yml index 52cba30a4d5..b6200ce0ab1 100644 --- a/.github/workflows/build-develop-pr.yml +++ b/.github/workflows/build-develop-pr.yml @@ -30,10 +30,10 @@ jobs: build-config: | [ { - "flavor": "Dev", - "variant": "Debug", - "keystore-type": "debug", - "build-type": "apk", + "flavor": "Alpha", + "variant": "Compat", + "keystore-type": "internal", + "build-type": "both", "generate-version-file": false, "deployment-targets": [ { From bcdeb5b9a7e94a3e7a0a4e4e066b3f9d04a1f768 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 18 Aug 2026 14:28:11 +0200 Subject: [PATCH 07/13] chore: update Kalium for Navigation 3 CI [MOB-20] --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index be50fd39163..87923b86ae4 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit be50fd391634e477b4f4887e0d42f8ab503d1f2b +Subproject commit 87923b86ae4b04db28e32c462b97eb1fc4705aaa From 4614f13a677ae5710403650095eef9b745df9f33 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:27:35 +0200 Subject: [PATCH 08/13] fix: use unique keys for conversation list paging items --- .../common/ConversationList.kt | 29 +++++++----- .../common/ConversationListItemKeyTest.kt | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationListItemKeyTest.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index ebf9e91fb7f..b57dac90284 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -46,7 +46,6 @@ import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.model.BadgeEventType @@ -112,16 +111,8 @@ fun ConversationList( ) { items( count = lazyPagingConversations.itemCount, - key = lazyPagingConversations.itemKey { - when (it) { - is ConversationSection.Custom -> "section_custom_${it.sectionName}" - is ConversationSection.WithoutHeader -> "section_without_header" - is ConversationItem -> it.conversationId.toString() - ConversationSection.Predefined.BrowseChannels -> "section_predefined_browse_channels" - ConversationSection.Predefined.Conversations -> "section_predefined_conversations" - ConversationSection.Predefined.Favorites -> "section_predefined_favorites" - ConversationSection.Predefined.NewActivities -> "section_predefined_new_activities" - } + key = { index -> + conversationListItemKey(lazyPagingConversations.peek(index), index) }, contentType = lazyPagingConversations.itemContentType { when (it) { @@ -188,6 +179,22 @@ fun ConversationList( } } +/** + * Conversation IDs remain stable while paging refreshes. Section rows are transient separators, + * and Paging can briefly expose the same separator more than once while generations are swapped. + * Their position is therefore part of the key so that such a transient frame remains renderable. + */ +internal fun conversationListItemKey(item: ConversationItemType?, index: Int): String = when (item) { + is ConversationItem -> item.conversationId.toString() + is ConversationSection.Custom -> "section_custom_${item.sectionName}_$index" + is ConversationSection.WithoutHeader -> "section_without_header_$index" + ConversationSection.Predefined.BrowseChannels -> "section_predefined_browse_channels_$index" + ConversationSection.Predefined.Conversations -> "section_predefined_conversations_$index" + ConversationSection.Predefined.Favorites -> "section_predefined_favorites_$index" + ConversationSection.Predefined.NewActivities -> "section_predefined_new_activities_$index" + null -> "conversation_placeholder_$index" +} + @Composable private fun BrowsePublicChannelsItem(onBrowsePublicChannels: () -> Unit = {}) { RowItemTemplate( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationListItemKeyTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationListItemKeyTest.kt new file mode 100644 index 00000000000..99d05e4a997 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationListItemKeyTest.kt @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.ui.home.conversationslist.common + +import com.wire.android.ui.home.conversationslist.model.ConversationSection +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ConversationListItemKeyTest { + + @Test + fun givenDuplicateConversationSeparators_whenCreatingKeys_thenEachPositionIsUnique() { + val separator = ConversationSection.Predefined.Conversations + + val first = conversationListItemKey(separator, index = 3) + val duplicate = conversationListItemKey(separator, index = 8) + + assertNotEquals(first, duplicate) + } + + @Test + fun givenSameSeparatorAtSamePosition_whenCreatingKeyAgain_thenKeyIsStable() { + val separator = ConversationSection.Predefined.Conversations + + assertEquals( + conversationListItemKey(separator, index = 3), + conversationListItemKey(separator, index = 3), + ) + } + + @Test + fun givenPagingPlaceholders_whenCreatingKeys_thenEachPositionIsUnique() { + assertNotEquals( + conversationListItemKey(item = null, index = 3), + conversationListItemKey(item = null, index = 8), + ) + } +} From a3b4613a53e766d9d045280c12d11935110325fd Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:27:47 +0200 Subject: [PATCH 09/13] fix: isolate new login password attempt lifecycle --- .../auth/AuthenticationLegacyMappers.kt | 17 +++++++++++ .../auth/AuthenticationNavigation3Entries.kt | 2 +- .../auth/CreateAccountNavigation3Entries.kt | 2 +- .../routes/auth/AuthenticationRoutesTest.kt | 28 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt index 38cb213f751..75d36dbe25b 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationLegacyMappers.kt @@ -92,6 +92,23 @@ internal fun LoginNavArgs.toNewLoginPasswordRoute( entryId: WireNavEntryId = WireNavEntryId.random(), ): NewLoginPasswordRoute = NewLoginPasswordRoute(toAuthenticationArguments(), flowId, entryId) +/** + * Starts an isolated password attempt within the wider authentication flow. + * + * [LoginEmailViewModel] is shared with the verification-code entry, so both entries need a Flow + * owner. That owner must not be the parent login flow, however: after cancellation it retains a + * terminal [com.wire.android.ui.authentication.login.LoginState.Canceled] state and would + * immediately cancel the next password attempt. Tying the attempt flow to its first entry gives a + * retry a fresh owner while password -> verification continues to share one ViewModel. + */ +internal fun LoginNavArgs.toNewLoginPasswordAttemptRoute( + entryId: WireNavEntryId = WireNavEntryId.random(), +): NewLoginPasswordRoute = NewLoginPasswordRoute( + args = toAuthenticationArguments(), + flowId = "new-login-password:${entryId.value}", + entryId = entryId, +) + internal fun NewLoginPasswordRoute.toLegacyNavArgs(): LoginNavArgs = args.toLegacy() internal fun LoginNavArgs.toNewLoginVerificationCodeRoute( diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt index 66063dc0d69..ff5c42bbe3e 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Entries.kt @@ -350,7 +350,7 @@ private fun handleNewLoginAction( action.userIdentifier ), loginPasswordPath = action.loginPasswordPath, - ).toNewLoginPasswordRoute(route.flowId), + ).toNewLoginPasswordAttemptRoute(), ) is NewLoginAction.CustomConfig -> router.navigate( diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt index 08e3b9755bc..6fd1f036f87 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3Entries.kt @@ -97,7 +97,7 @@ private fun CreateAccountSelectorNavigation3Entry( val returnRoute = LoginNavArgs( userHandle = PreFilledUserIdentifierType.PreFilled(viewModel.email), loginPasswordPath = LoginPasswordPath(customServerConfig = viewModel.serverConfig), - ).toNewLoginPasswordRoute(route.flowId) + ).toNewLoginPasswordAttemptRoute() actions.openTeamAccountCreation( AuthenticationTeamAccountCreationRequest(url, returnRoute) ) diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt index 223662e9726..88b60245e95 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationRoutesTest.kt @@ -150,6 +150,34 @@ class AuthenticationRoutesTest { ) } + @Test + fun givenRepeatedPasswordAttempts_whenCreatingRoutes_thenEachAttemptGetsAnIsolatedFlowOwner() { + val legacy = completeLegacyLoginArguments() + + val firstAttempt = legacy.toNewLoginPasswordAttemptRoute( + entryId = WireNavEntryId("password-attempt-one"), + ) + val secondAttempt = legacy.toNewLoginPasswordAttemptRoute( + entryId = WireNavEntryId("password-attempt-two"), + ) + + assertEquals("new-login-password:password-attempt-one", firstAttempt.flowId) + assertEquals("new-login-password:password-attempt-two", secondAttempt.flowId) + assertNotEquals(firstAttempt.flowId, secondAttempt.flowId) + } + + @Test + fun givenPasswordAttempt_whenOpeningVerification_thenVerificationKeepsAttemptOwner() { + val passwordAttempt = completeLegacyLoginArguments().toNewLoginPasswordAttemptRoute( + entryId = WireNavEntryId("password-attempt"), + ) + + val verification = passwordAttempt.toLegacyNavArgs() + .toNewLoginVerificationCodeRoute(passwordAttempt.flowId) + + assertEquals(passwordAttempt.flowId, verification.flowId) + } + @Test fun givenWelcomeArguments_whenMappedToTypedSerializedAndBack_thenServerLinksArePreserved() { val legacy = WelcomeNavArgs(customServerConfig = SERVER_LINKS) From b6acaaf5bed0d66facd912c9c11d4d71533ea859 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:29:07 +0200 Subject: [PATCH 10/13] fix: finalize cancelled authentication session graph --- .../WireNavigation3ProductionActions.kt | 3 + .../WireNavigation3SwitchAccountActions.kt | 36 +++++++++++ .../android/ui/WireActivityNavigation3Host.kt | 12 ++++ .../devices/DeviceE2EINavigation3Entries.kt | 22 ++++++- ...WireNavigation3SwitchAccountActionsTest.kt | 59 +++++++++++++++++++ 5 files changed, 129 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt index 0d8034b2084..82fdcae25af 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt @@ -105,6 +105,7 @@ internal data class WireNavigation3ActivityCallbacks( val hardLogout: () -> Unit, val restartAfterLogout: () -> Unit, val moveTaskToBackground: () -> Unit, + val completeSessionBackedAuthenticationCancellation: (WireSessionId) -> Unit, ) internal enum class WireNavigation3ExternalIntent { @@ -276,6 +277,8 @@ internal class WireNavigation3ProductionActions( override fun exitSettings() = goBackOrFinish() override fun exitDeviceManagement() = goBackOrFinish() + override fun completeSessionBackedAuthenticationCancellation(sessionId: WireSessionId) = + activity.completeSessionBackedAuthenticationCancellation(sessionId) override fun exitUserProfile() = goBackOrFinish() override fun openAppSettings() = navigate(AppSettingsRoute(requireSession())) override fun openAddAccount() { diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt index 9dcb83e630c..b6dd314d9de 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActions.kt @@ -19,6 +19,7 @@ import com.wire.android.navigation.routes.auth.WelcomeRoute import com.wire.navigation.WireBackStackMode import com.wire.navigation.WireNavigationCommand import com.wire.navigation.WireRoute +import com.wire.navigation.WireSessionId /** * Navigation 3 account-switch bridge. @@ -41,6 +42,41 @@ internal fun WireNavigation3ProductionActions.asSwitchAccountActions(): SwitchAc onNoOtherAccountToSwitch = ::noOtherAccountToSwitch, ) +/** + * Completes navigation away from session-backed authentication before its temporary session is + * deleted. Both outcomes must close the same graph generation: either another account becomes + * active or the app returns to login with no active account. + */ +internal fun sessionCancellationSwitchAccountActions( + delegate: SwitchAccountActions, + sessionId: WireSessionId, + onNavigationCompleted: (WireSessionId) -> Unit, +): SwitchAccountActions = WireNavigation3SwitchAccountActions( + onSwitchedToAnotherAccount = { + completeSessionCancellation(sessionId, delegate::switchedToAnotherAccount, onNavigationCompleted) + }, + onNoOtherAccountToSwitch = { + completeSessionCancellation(sessionId, delegate::noOtherAccountToSwitch, onNavigationCompleted) + }, +) + +/** + * Graph teardown is mandatory even if the navigation callback fails: ClearSessionViewModel deletes + * the temporary session from its own NonCancellable finally block, so leaving its graph generation + * active would allow a later login to resolve stale state. The original failure is still rethrown. + */ +private inline fun completeSessionCancellation( + sessionId: WireSessionId, + navigateAway: () -> Unit, + onNavigationCompleted: (WireSessionId) -> Unit, +) { + try { + navigateAway() + } finally { + onNavigationCompleted(sessionId) + } +} + internal fun noOtherAccountNavigationCommand( currentRoute: WireRoute?, useNewLogin: Boolean, diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt index 8f73f78d038..a34a2a4a38d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityNavigation3Host.kt @@ -294,6 +294,18 @@ private fun WireActivityNavigation3ThemedHost( }, restartAfterLogout = activityEffects::restartAfterLogout, moveTaskToBackground = { dependencies.activity.moveTaskToBack(true) }, + completeSessionBackedAuthenticationCancellation = { sessionId -> + teardownWireActivitySession( + sessionId = sessionId, + markInvalidating = sessionGraphStore::markInvalidating, + clearOwner = { ownerKey -> + clearWireViewModelStoreOwner(sharedViewModelStoreProvider, ownerKey) { + WireViewModelDiagnostics.ownerCleared(ownerKey) + } + }, + markRemoved = sessionGraphStore::markRemoved, + ) + }, ) } val actions = rememberWireNavigation3ProductionActions( diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt index 4440e133fb6..1fbf553bc2a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt @@ -20,6 +20,7 @@ import com.wire.android.navigation.navigation3.wireEntry import com.wire.android.navigation.navigation3.wireViewModelStoreOwner import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.navigation.runtime.sessionCancellationSwitchAccountActions import com.wire.android.ui.authentication.clearSessionViewModel import com.wire.android.ui.authentication.registerDeviceViewModel import com.wire.android.ui.authentication.removeDeviceViewModel @@ -62,6 +63,9 @@ internal interface DeviceE2EINavigation3Actions : SwitchAccountActions { * Used when a restored/deep-linked device route has no parent entry to pop to. */ fun exitDeviceManagement() + + /** Finalizes the graph generation after navigation leaves a cancelled login session. */ + fun completeSessionBackedAuthenticationCancellation(sessionId: WireSessionId) } internal object DeviceE2EINavigation3Contribution { @@ -111,7 +115,11 @@ private fun RegisterDeviceNavigation3Entry( RegisterDeviceRouteScreen( viewModel = registerDeviceViewModel(flowOwner), clearSessionViewModel = clearSessionViewModel(flowOwner), - switchAccountActions = actions, + switchAccountActions = sessionCancellationSwitchAccountActions( + delegate = actions, + sessionId = route.sessionId, + onNavigationCompleted = actions::completeSessionBackedAuthenticationCancellation, + ), onE2EIRequired = { userId -> authenticationRouter.registerDeviceToE2EI( sessionId = userId?.let { WireSessionId(it.value, it.domain) } ?: route.sessionId, @@ -140,7 +148,11 @@ private fun RemoveDeviceNavigation3Entry( RemoveDeviceRouteScreen( viewModel = removeDeviceViewModel(flowOwner), clearSessionViewModel = clearSessionViewModel(flowOwner), - switchAccountActions = actions, + switchAccountActions = sessionCancellationSwitchAccountActions( + delegate = actions, + sessionId = route.sessionId, + onNavigationCompleted = actions::completeSessionBackedAuthenticationCancellation, + ), onE2EIRequired = { authenticationRouter.removeDeviceToE2EI(route.sessionId, route.flowId) }, @@ -163,7 +175,11 @@ private fun E2EIEnrollmentNavigation3Entry( E2EIEnrollmentRouteScreen( viewModel = e2EIEnrollmentViewModel(flowOwner), clearSessionViewModel = clearSessionViewModel(flowOwner), - switchAccountActions = actions, + switchAccountActions = sessionCancellationSwitchAccountActions( + delegate = actions, + sessionId = route.sessionId, + onNavigationCompleted = actions::completeSessionBackedAuthenticationCancellation, + ), onInitialSyncRequired = { authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.INITIAL_SYNC) }, diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt index 45cabefa366..36863ae9dd0 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3SwitchAccountActionsTest.kt @@ -17,6 +17,7 @@ import com.wire.navigation.WireSessionId import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test class WireNavigation3SwitchAccountActionsTest { @@ -45,6 +46,64 @@ class WireNavigation3SwitchAccountActionsTest { assertEquals(1, empty) } + @Test + fun givenCancelledSessionSwitchesAccount_whenNotified_thenNavigationCompletesBeforeGraphTeardown() { + val sessionId = WireSessionId("cancelled", "example.com") + val events = mutableListOf() + val delegate = WireNavigation3SwitchAccountActions( + onSwitchedToAnotherAccount = { events += "navigate" }, + onNoOtherAccountToSwitch = { events += "login" }, + ) + val actions = sessionCancellationSwitchAccountActions(delegate, sessionId) { + events += "remove:${it.value}@${it.domain}" + } + + actions.switchedToAnotherAccount() + + assertEquals(listOf("navigate", "remove:cancelled@example.com"), events) + } + + @Test + fun givenCancelledSessionHasNoOtherAccount_whenNotified_thenLoginCompletesBeforeGraphTeardown() { + val sessionId = WireSessionId("cancelled", "example.com") + val events = mutableListOf() + val delegate = WireNavigation3SwitchAccountActions( + onSwitchedToAnotherAccount = { events += "navigate" }, + onNoOtherAccountToSwitch = { events += "login" }, + ) + val actions = sessionCancellationSwitchAccountActions(delegate, sessionId) { + events += "remove:${it.value}@${it.domain}" + } + + actions.noOtherAccountToSwitch() + + assertEquals(listOf("login", "remove:cancelled@example.com"), events) + } + + @Test + fun givenNavigationFailsDuringSessionCancellation_whenNotified_thenGraphIsStillTornDownAndFailureIsRethrown() { + val sessionId = WireSessionId("cancelled", "example.com") + val events = mutableListOf() + val failure = IllegalStateException("navigation failed") + val delegate = WireNavigation3SwitchAccountActions( + onSwitchedToAnotherAccount = { + events += "navigate" + throw failure + }, + onNoOtherAccountToSwitch = { error("unused") }, + ) + val actions = sessionCancellationSwitchAccountActions(delegate, sessionId) { + events += "remove:${it.value}@${it.domain}" + } + + val thrown = assertThrows(IllegalStateException::class.java) { + actions.switchedToAnotherAccount() + } + + assertEquals(failure, thrown) + assertEquals(listOf("navigate", "remove:cancelled@example.com"), events) + } + @Test fun givenNewLoginIsAlreadyVisible_whenNoOtherAccountRemains_thenDoNotNavigateAgain() { val command = noOtherAccountNavigationCommand( From 57579e506fbaa78834e97fa4184b1cd421531c86 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:29:54 +0200 Subject: [PATCH 11/13] fix: make register device completion idempotent --- .../auth/AuthenticationNavigation3Router.kt | 62 +++++++++++++++---- .../devices/register/RegisterDeviceScreen.kt | 29 ++++++--- .../devices/DeviceE2EINavigation3Entries.kt | 33 ++++++++-- .../AuthenticationNavigation3RouterTest.kt | 55 ++++++++++++++++ .../DeviceE2EINavigation3EntriesSourceTest.kt | 18 ++++++ 5 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterTest.kt diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt index a7c3acf5c03..671c3a35f98 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3Router.kt @@ -73,6 +73,14 @@ internal enum class AuthenticationNavigationTransition { BACK, } +/** Terminal outcome produced by the register-device flow and consumed by its router. */ +internal sealed interface RegisterDeviceCompletion { + data class E2EIEnrollment(val sessionId: WireSessionId) : RegisterDeviceCompletion + data object Home : RegisterDeviceCompletion + data object InitialSync : RegisterDeviceCompletion + data object RemoveDevice : RegisterDeviceCompletion +} + internal class AuthenticationNavigation3Router( private val runtime: WireNavigation3Runtime, private val transitionLedger: AuthenticationTransitionLedger = AuthenticationTransitionLedger(), @@ -170,20 +178,48 @@ internal class AuthenticationNavigation3Router( fun completeLogin( eventId: String, completion: AuthenticationLoginCompletion, - ): Boolean = - when (transitionLedger.executeOnce(eventId) { completeLogin(completion) }) { - AuthenticationTransitionLedger.Outcome.APPLIED -> true - AuthenticationTransitionLedger.Outcome.REJECTED -> false - AuthenticationTransitionLedger.Outcome.ALREADY_APPLIED -> { - val transitionId = WireNavigationDiagnostics.nextTransitionId() - WireNavigationDiagnostics.auth( - transitionId = transitionId, - event = "LOGIN_TERMINAL", - outcome = "duplicate-ignored", - ) - true - } + ): Boolean = executeTerminalTransitionOnce(eventId, "LOGIN_TERMINAL") { + completeLogin(completion) + } + + fun completeRegisterDevice( + eventId: String, + routeSessionId: WireSessionId, + flowId: String, + completion: RegisterDeviceCompletion, + ): Boolean = executeTerminalTransitionOnce(eventId, "REGISTER_DEVICE_TERMINAL") { + when (completion) { + is RegisterDeviceCompletion.E2EIEnrollment -> + registerDeviceToE2EI(completion.sessionId, flowId) + + RegisterDeviceCompletion.Home -> + completeSessionSetup(routeSessionId, SessionSetupDestination.HOME) + + RegisterDeviceCompletion.InitialSync -> + completeSessionSetup(routeSessionId, SessionSetupDestination.INITIAL_SYNC) + + RegisterDeviceCompletion.RemoveDevice -> + registerDeviceToRemoveDevice(routeSessionId, flowId) } + } + + private fun executeTerminalTransitionOnce( + eventId: String, + eventName: String, + transition: () -> Boolean, + ): Boolean = when (transitionLedger.executeOnce(eventId, transition)) { + AuthenticationTransitionLedger.Outcome.APPLIED -> true + AuthenticationTransitionLedger.Outcome.REJECTED -> false + AuthenticationTransitionLedger.Outcome.ALREADY_APPLIED -> { + val transitionId = WireNavigationDiagnostics.nextTransitionId() + WireNavigationDiagnostics.auth( + transitionId = transitionId, + event = eventName, + outcome = "duplicate-ignored", + ) + true + } + } fun completeSessionSetup( sessionId: WireSessionId, diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt index e3d8fa55bb7..0967ebfeb99 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -72,16 +73,30 @@ internal fun RegisterDeviceRouteScreen( onRemoveDeviceRequired: () -> Unit, ) { clearAutofillTree() - when (val flowState = viewModel.state.flowState) { - is RegisterDeviceFlowState.Success -> { - when { - flowState.isE2EIRequired -> onE2EIRequired(flowState.userId) - flowState.initialSyncCompleted -> onHomeRequired() - else -> onInitialSyncRequired() + + val flowState = viewModel.state.flowState + LaunchedEffect(flowState) { + when (flowState) { + is RegisterDeviceFlowState.Success -> { + when { + flowState.isE2EIRequired -> onE2EIRequired(flowState.userId) + flowState.initialSyncCompleted -> onHomeRequired() + else -> onInitialSyncRequired() + } } + + is RegisterDeviceFlowState.TooManyDevices -> onRemoveDeviceRequired() + else -> Unit + } + } + + when (flowState) { + is RegisterDeviceFlowState.Success -> { + // The terminal result is handled by the effect above. Rendering stays empty while + // Navigation 3 commits and animates the accepted transition. } - is RegisterDeviceFlowState.TooManyDevices -> onRemoveDeviceRequired() + is RegisterDeviceFlowState.TooManyDevices -> Unit else -> AnimatedContent( targetState = viewModel.secondFactorVerificationCodeState.isCodeInputNecessary, diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt index 1fbf553bc2a..426b3cc7b96 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3Entries.kt @@ -20,6 +20,7 @@ import com.wire.android.navigation.navigation3.wireEntry import com.wire.android.navigation.navigation3.wireViewModelStoreOwner import com.wire.android.navigation.routes.auth.AuthenticationNavigation3Router import com.wire.android.navigation.routes.auth.AuthenticationNavigationTransition +import com.wire.android.navigation.routes.auth.RegisterDeviceCompletion import com.wire.android.navigation.runtime.sessionCancellationSwitchAccountActions import com.wire.android.ui.authentication.clearSessionViewModel import com.wire.android.ui.authentication.registerDeviceViewModel @@ -121,23 +122,45 @@ private fun RegisterDeviceNavigation3Entry( onNavigationCompleted = actions::completeSessionBackedAuthenticationCancellation, ), onE2EIRequired = { userId -> - authenticationRouter.registerDeviceToE2EI( - sessionId = userId?.let { WireSessionId(it.value, it.domain) } ?: route.sessionId, + authenticationRouter.completeRegisterDevice( + eventId = route.registerDeviceTerminalEventId(), + routeSessionId = route.sessionId, flowId = route.flowId, + completion = RegisterDeviceCompletion.E2EIEnrollment( + userId?.let { WireSessionId(it.value, it.domain) } ?: route.sessionId, + ), ) }, onHomeRequired = { - authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.HOME) + authenticationRouter.completeRegisterDevice( + eventId = route.registerDeviceTerminalEventId(), + routeSessionId = route.sessionId, + flowId = route.flowId, + completion = RegisterDeviceCompletion.Home, + ) }, onInitialSyncRequired = { - authenticationRouter.completeSessionSetup(route.sessionId, SessionSetupDestination.INITIAL_SYNC) + authenticationRouter.completeRegisterDevice( + eventId = route.registerDeviceTerminalEventId(), + routeSessionId = route.sessionId, + flowId = route.flowId, + completion = RegisterDeviceCompletion.InitialSync, + ) }, onRemoveDeviceRequired = { - authenticationRouter.registerDeviceToRemoveDevice(route.sessionId, route.flowId) + authenticationRouter.completeRegisterDevice( + eventId = route.registerDeviceTerminalEventId(), + routeSessionId = route.sessionId, + flowId = route.flowId, + completion = RegisterDeviceCompletion.RemoveDevice, + ) }, ) } +private fun RegisterDeviceRoute.registerDeviceTerminalEventId(): String = + "${entryId.value}:register-device-terminal" + @Composable private fun RemoveDeviceNavigation3Entry( route: RemoveDeviceRoute, diff --git a/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterTest.kt b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterTest.kt new file mode 100644 index 00000000000..7ad5557c471 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/navigation/routes/auth/AuthenticationNavigation3RouterTest.kt @@ -0,0 +1,55 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.navigation.routes.auth + +import com.wire.android.navigation.navigation3.WireNavigation3Runtime +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute +import com.wire.navigation.WireNavigationCommand +import com.wire.navigation.WireNavigationController +import com.wire.navigation.WireSessionId +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class AuthenticationNavigation3RouterTest { + + @Test + fun givenRegisterDeviceTerminalStateReplays_whenCompleting_thenBackStackIsMutatedOnlyOnce() { + val runtime = mockk() + val navigator = mockk() + val command = slot() + every { runtime.navigator } returns navigator + every { navigator.navigate(capture(command)) } returns true + val router = AuthenticationNavigation3Router(runtime) + val sessionId = WireSessionId("self", "wire.test") + + repeat(10) { + assertTrue( + router.completeRegisterDevice( + eventId = "register-entry:terminal", + routeSessionId = sessionId, + flowId = "register-flow", + completion = RegisterDeviceCompletion.RemoveDevice, + ) + ) + } + + verify(exactly = 1) { navigator.navigate(any()) } + val destination = assertInstanceOf(RemoveDeviceRoute::class.java, command.captured.destination) + assertEquals(sessionId, destination.sessionId) + assertEquals("register-flow", destination.flowId) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt index 1ce054c18a2..5b14e441cb3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt @@ -77,6 +77,24 @@ class DeviceE2EINavigation3EntriesSourceTest { } } + @Test + fun givenRegisterDeviceCompletes_whenInspectingFlow_thenNavigationIsAppliedOnlyOnce() { + val screen = sourceFile( + "ui/authentication/devices/register/RegisterDeviceScreen.kt" + ).readText() + val entries = sourceFile( + "ui/settings/devices/DeviceE2EINavigation3Entries.kt" + ).readText() + val router = sourceFile( + "navigation/routes/auth/AuthenticationNavigation3Router.kt" + ).readText() + + assertTrue(screen.contains("LaunchedEffect(flowState)")) + assertTrue(entries.contains("route.registerDeviceTerminalEventId()")) + assertTrue(router.contains("executeTerminalTransitionOnce(eventId, \"REGISTER_DEVICE_TERMINAL\")")) + assertFalse(screen.contains("is RegisterDeviceFlowState.TooManyDevices -> onRemoveDeviceRequired()\n else ->")) + } + private fun sourceFile(relativePath: String): File { val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } .first { File(it, "app/src/main/kotlin").isDirectory } From dc15caa3ab4bfb179e421bd14ca28aae2752dd73 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:30:04 +0200 Subject: [PATCH 12/13] fix: navigate back once after managed device removal --- .../ui/settings/devices/DeviceDetailsScreen.kt | 12 ++++++++---- .../DeviceE2EINavigation3EntriesSourceTest.kt | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index 917d596b01d..dcebbb4ee27 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -101,10 +102,13 @@ internal fun DeviceDetailsRouteScreen( onNavigateBack: () -> Unit, onOpenCertificateDetails: (MLSClientIdentity) -> Unit, ) { - when { - viewModel.state.error is RemoveDeviceError.InitError -> onNavigateBack() - viewModel.state.deviceRemoved -> onNavigateBack() - else -> DeviceDetailsContent( + val shouldNavigateBack = viewModel.state.error is RemoveDeviceError.InitError || + viewModel.state.deviceRemoved + LaunchedEffect(shouldNavigateBack) { + if (shouldNavigateBack) onNavigateBack() + } + if (!shouldNavigateBack) { + DeviceDetailsContent( state = viewModel.state, passwordTextState = viewModel.passwordTextState, onDeleteDevice = viewModel::removeDevice, diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt index 5b14e441cb3..98d1dbd45cd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceE2EINavigation3EntriesSourceTest.kt @@ -77,6 +77,15 @@ class DeviceE2EINavigation3EntriesSourceTest { } } + @Test + fun givenDeviceRemovalCompletes_whenInspectingScreen_thenBackNavigationIsAOneShotEffect() { + val source = sourceFile("ui/settings/devices/DeviceDetailsScreen.kt").readText() + + assertTrue(source.contains("LaunchedEffect(shouldNavigateBack)")) + assertFalse(source.contains("viewModel.state.deviceRemoved -> onNavigateBack()")) + assertFalse(source.contains("RemoveDeviceError.InitError -> onNavigateBack()")) + } + @Test fun givenRegisterDeviceCompletes_whenInspectingFlow_thenNavigationIsAppliedOnlyOnce() { val screen = sourceFile( From 84d0f080bb282eb1f078f8025dd3e1c2343ad964 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Wed, 19 Aug 2026 21:30:14 +0200 Subject: [PATCH 13/13] fix: separate authentication policy from background presentation --- .../routes/auth/CreateAccountNavigation3.kt | 5 +++-- .../runtime/WireNavigation3ActivityPolicy.kt | 3 ++- .../WireNavigation3ActivityPolicyTest.kt | 22 ++++++++++++++++++- .../kotlin/com/wire/navigation/WireRoute.kt | 11 +++++++++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt index 295e0b23e27..828801f08ef 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/auth/CreateAccountNavigation3.kt @@ -10,6 +10,7 @@ package com.wire.android.navigation.routes.auth +import com.wire.navigation.AuthBackgroundRoute import com.wire.navigation.AuthenticationRoute import com.wire.navigation.AuthenticationScreenRoute import com.wire.navigation.SessionRoute @@ -185,7 +186,7 @@ data class CreateAccountSummaryRoute( override val sessionId: WireSessionId, override val flowId: String, override val entryId: WireNavEntryId = WireNavEntryId.random(), -) : SessionRoute, AuthenticationScreenRoute { +) : SessionRoute, AuthenticationScreenRoute, AuthBackgroundRoute { override val routeId: String get() = ROUTE_ID init { @@ -202,7 +203,7 @@ data class CreateAccountUsernameRoute( override val sessionId: WireSessionId, override val flowId: String, override val entryId: WireNavEntryId = WireNavEntryId.random(), -) : SessionRoute, AuthenticationScreenRoute { +) : SessionRoute, AuthenticationScreenRoute, AuthBackgroundRoute { override val routeId: String get() = ROUTE_ID init { diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt index 52dfda96ce3..2e50785dfef 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicy.kt @@ -12,6 +12,7 @@ package com.wire.android.navigation.runtime import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute import com.wire.android.navigation.style.BackgroundType +import com.wire.navigation.AuthBackgroundRoute import com.wire.navigation.AuthenticationScreenRoute import com.wire.navigation.SessionRoute import com.wire.navigation.WireRoute @@ -26,7 +27,7 @@ import com.wire.navigation.WireRoute internal object WireNavigation3ActivityPolicy { fun backgroundType(route: WireRoute?): BackgroundType = - if (route is AuthenticationScreenRoute) BackgroundType.Auth else BackgroundType.Default + if (route is AuthBackgroundRoute) BackgroundType.Auth else BackgroundType.Default fun sessionSnapshot( route: WireRoute?, diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt index 9082229073a..11fb6ad2ffc 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ActivityPolicyTest.kt @@ -12,10 +12,14 @@ package com.wire.android.navigation.runtime import com.wire.android.navigation.routes.auth.NewWelcomeEmptyStartRoute import com.wire.android.navigation.routes.auth.WelcomeRoute +import com.wire.android.navigation.routes.utility.InitialSyncRoute import com.wire.android.navigation.style.BackgroundType +import com.wire.android.ui.authentication.devices.register.RegisterDeviceRoute +import com.wire.android.ui.authentication.devices.remove.RemoveDeviceRoute import com.wire.navigation.AuthenticationScreenRoute import com.wire.navigation.SessionRoute import com.wire.navigation.WireNavEntryId +import com.wire.navigation.WireRoute import com.wire.navigation.WireSessionId import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -51,7 +55,7 @@ class WireNavigation3ActivityPolicyTest { isSelfLogoutTransition = true, ) - assertEquals(BackgroundType.Auth, WireNavigation3ActivityPolicy.backgroundType(route)) + assertEquals(BackgroundType.Default, WireNavigation3ActivityPolicy.backgroundType(route)) assertTrue(snapshot.isAuthenticationRoute) assertTrue(snapshot.isSessionBackedAuthenticationRoute) assertTrue(snapshot.isUserUiBlocked) @@ -59,6 +63,22 @@ class WireNavigation3ActivityPolicyTest { assertTrue(snapshot.isSelfLogoutTransition) } + @Test + fun givenSessionSetupRoutes_whenResolvingBackground_thenRegularBackgroundIsUsed() { + val sessionId = WireSessionId("user", "wire.example") + + val routes: List = listOf( + RegisterDeviceRoute(sessionId), + RemoveDeviceRoute(sessionId), + InitialSyncRoute(sessionId), + ) + + routes.forEach { route -> + assertEquals(BackgroundType.Default, WireNavigation3ActivityPolicy.backgroundType(route)) + assertTrue(route is AuthenticationScreenRoute) + } + } + @Test fun givenEmptyWelcomeOrNoRoute_whenResolvingPolicy_thenStartupFlagsAreExact() { val empty = WireNavigation3ActivityPolicy.sessionSnapshot( diff --git a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt index 153974f1c58..f7ce9f4aff5 100644 --- a/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt +++ b/core/navigation-kmp/src/commonMain/kotlin/com/wire/navigation/WireRoute.kt @@ -80,10 +80,19 @@ interface WireRoute : NavKey { */ interface AuthenticationScreenRoute : WireRoute +/** + * Marks a route whose Activity chrome uses the authentication background. + * + * This is deliberately separate from [AuthenticationScreenRoute]: session setup screens still + * belong to authentication policy and analytics, but visually use the regular application + * background. + */ +interface AuthBackgroundRoute : WireRoute + /** * Marks an authentication screen that does not require an authenticated session graph. */ -interface AuthenticationRoute : AuthenticationScreenRoute +interface AuthenticationRoute : AuthenticationScreenRoute, AuthBackgroundRoute /** * Marks a route whose dependencies belong to a specific account session.