From 6ee982223d3f3dfc4d4d7d8738211d5405e54def Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:24:32 +0200 Subject: [PATCH 1/5] fix(navigation): reset graph state after client removal --- .../android/ui/WireActivityNavigatorTest.kt | 56 +++++++++++++++++++ .../com/wire/android/ui/WireActivity.kt | 22 +++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt index c09940c694a..e37d2733b3f 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt @@ -21,7 +21,11 @@ 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 @@ -63,4 +67,56 @@ class WireActivityNavigatorTest { 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 = rememberWireActivityCurrentBackStackEntryState(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/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 4efee159fb3..b8aed1a8c4a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -39,7 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -63,6 +63,7 @@ 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 @@ -393,7 +394,7 @@ class WireActivity : BaseActivity() { finish = this@WireActivity::finish, isAllowedToNavigate = ::isNavigationAllowed ) - val currentBackStackEntryState = navigator.navController.currentBackStackEntryAsState() + val currentBackStackEntryState = rememberWireActivityCurrentBackStackEntryState(navigator) val currentBaseRoute = currentBackStackEntryState.value ?.destination ?.route @@ -449,12 +450,8 @@ class WireActivity : BaseActivity() { currentBaseRoute != null && canRetainSessionGraphForContent -> lastSessionGraphContext.value else -> null } - val backgroundType by remember { - derivedStateOf { - currentBackStackEntryState.value?.safeDestination()?.style.let { - (it as? BackgroundStyle)?.backgroundType() ?: BackgroundType.Default - } - } + val backgroundType = currentBackStackEntryState.value?.safeDestination()?.style.let { + (it as? BackgroundStyle)?.backgroundType() ?: BackgroundType.Default } HandleSessionGraphEffects( @@ -1276,6 +1273,15 @@ internal fun observeAppLockUserId( if (isLocked) userId else null }.filterNotNull() +@Composable +internal fun rememberWireActivityCurrentBackStackEntryState( + 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, From 745d26996e56b3ef81d3d341b81c3fee2c32e0b0 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:41:56 +0200 Subject: [PATCH 2/5] fix(session): clear removed client state on relogin --- .../com/wire/android/ui/WireActivity.kt | 86 +++++++++-- .../feature/AccountSwitchUseCaseTest.kt | 22 ++- .../ui/SessionGraphStoreViewModelTest.kt | 135 ++++++++++++++++++ .../ui/WireActivityActiveGraphResolverTest.kt | 30 ++++ .../android/util/ui/WireSessionImageLoader.kt | 5 + .../util/ui/WireSessionImageLoaderTest.kt | 41 ++++++ kalium | 2 +- .../core/e2eTests/MultiAccountSessionScope.kt | 40 +++++- .../src/main/service/TestServiceHelper.kt | 31 ++++ 9 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt create mode 100644 core/ui-common/src/test/kotlin/com/wire/android/util/ui/WireSessionImageLoaderTest.kt 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 b8aed1a8c4a..213c905aba1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -57,6 +58,8 @@ 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 @@ -347,7 +350,7 @@ class WireActivity : BaseActivity() { sessionGraphStore: SessionGraphStoreViewModel = viewModel( factory = viewModelFactory { initializer { - SessionGraphStoreViewModel(appGraph) + SessionGraphStoreViewModel(appGraph::createSessionViewModelGraph) } } ), @@ -405,6 +408,7 @@ class WireActivity : BaseActivity() { ?.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 { @@ -438,6 +442,15 @@ class WireActivity : BaseActivity() { if (graphContext?.sessionGraph != null) { lastSessionGraphContext.value = graphContext } + val shouldInvalidateRetainedSessionGraph = shouldInvalidateWireActivitySessionGraph( + isUserUiBlocked = isUserUiBlocked, + sessionTransitionReason = sessionTransitionReason, + ) + HandleRetainedSessionGraphInvalidation( + shouldInvalidate = shouldInvalidateRetainedSessionGraph, + lastSessionGraphContext = lastSessionGraphContext, + sessionGraphStore = sessionGraphStore, + ) val graphContextWithRetainedImageLoader = graphContext?.copy( imageLoaderSessionGraph = resolveWireActivityImageLoaderSessionGraph( activeSessionGraph = graphContext.sessionGraph, @@ -486,6 +499,20 @@ class WireActivity : BaseActivity() { noSessionAuthenticationStartedWithoutSession = noSessionAuthenticationStartedWithoutSession, ) + @Composable + private fun HandleRetainedSessionGraphInvalidation( + shouldInvalidate: Boolean, + lastSessionGraphContext: MutableState, + sessionGraphStore: SessionGraphStoreViewModel, + ) { + LaunchedEffect(shouldInvalidate) { + if (shouldInvalidate) { + lastSessionGraphContext.value?.sessionGraph?.currentAccount?.let(sessionGraphStore::invalidate) + lastSessionGraphContext.value = null + } + } + } + private fun isNavigationAllowed(navigationCommand: NavigationCommand): Boolean { if (navigationCommand.destination.baseRoute != NewLoginScreenDestination.baseRoute) return true @@ -611,7 +638,7 @@ class WireActivity : BaseActivity() { val usesAuthenticationGraph = effectiveBaseRoute in authenticationGraphRoutes val usesSessionBackedAuthenticationGraph = effectiveBaseRoute in sessionBackedAuthenticationGraphRoutes val usesInvalidSessionBackedAuthenticationGraph = usesSessionBackedAuthenticationGraph && currentUserId == null - val sessionGraph = remember( + val retainedSessionGraph = remember( appGraph, currentUserId, sessionBackedAuthenticationUserId, @@ -627,6 +654,7 @@ class WireActivity : BaseActivity() { isSessionTransitionInProgress = isSessionTransitionInProgress, ) } + val sessionGraph = retainedSessionGraph?.graph val graph = resolveActiveGraph( WireActivityActiveGraphRequest( authenticationViewModelGraph = authenticationViewModelGraph, @@ -639,7 +667,7 @@ class WireActivity : BaseActivity() { isSessionTransitionInProgress = isSessionTransitionInProgress, ) ) - val activityViewModels = sessionGraph?.let { + val activityViewModels = retainedSessionGraph?.let { wireActivityScopedViewModels(it) } return graph?.let { @@ -659,12 +687,12 @@ class WireActivity : BaseActivity() { usesNoSessionAuthenticationGraph: Boolean, usesInvalidSessionBackedAuthenticationGraph: Boolean, isSessionTransitionInProgress: Boolean, - ): AppSessionViewModelGraph? = when { + ): RetainedSessionGraph? = when { usesNoSessionAuthenticationGraph -> null usesInvalidSessionBackedAuthenticationGraph -> null isSessionTransitionInProgress -> null - sessionBackedAuthenticationUserId != null -> graphFor(sessionBackedAuthenticationUserId) - currentUserId != null -> graphFor(currentUserId) + sessionBackedAuthenticationUserId != null -> retainedGraphFor(sessionBackedAuthenticationUserId) + currentUserId != null -> retainedGraphFor(currentUserId) else -> null } @@ -743,10 +771,12 @@ class WireActivity : BaseActivity() { } @Composable - private fun wireActivityScopedViewModels(graph: AppSessionViewModelGraph): WireActivityScopedViewModels { + private fun wireActivityScopedViewModels(retainedSessionGraph: RetainedSessionGraph): WireActivityScopedViewModels { + val graph = retainedSessionGraph.graph val scopeKey = graph.viewModelScopeKey return WireActivityScopedViewModels( callFeedbackViewModel = viewModel( + viewModelStoreOwner = retainedSessionGraph, key = "CallFeedbackViewModel:$scopeKey", factory = viewModelFactory { initializer { @@ -755,6 +785,7 @@ class WireActivity : BaseActivity() { } ), featureFlagNotificationViewModel = viewModel( + viewModelStoreOwner = retainedSessionGraph, key = "FeatureFlagNotificationViewModel:$scopeKey", factory = viewModelFactory { initializer { @@ -763,6 +794,7 @@ class WireActivity : BaseActivity() { } ), commonTopAppBarViewModel = viewModel( + viewModelStoreOwner = retainedSessionGraph, key = "CommonTopAppBarViewModel:$scopeKey", factory = viewModelFactory { initializer { @@ -773,6 +805,7 @@ class WireActivity : BaseActivity() { } ), legalHoldRequestedViewModel = viewModel( + viewModelStoreOwner = retainedSessionGraph, key = "LegalHoldRequestedViewModel:$scopeKey", factory = viewModelFactory { initializer { @@ -781,6 +814,7 @@ class WireActivity : BaseActivity() { } ), legalHoldDeactivatedViewModel = viewModel( + viewModelStoreOwner = retainedSessionGraph, key = "LegalHoldDeactivatedViewModel:$scopeKey", factory = viewModelFactory { initializer { @@ -1298,16 +1332,39 @@ private data class WireActivityGraphContext( val activityViewModels: WireActivityScopedViewModels?, ) -private class SessionGraphStoreViewModel( - private val appGraph: WireApplicationGraph, +internal class SessionGraphStoreViewModel( + private val createSessionGraph: (UserId) -> AppSessionViewModelGraph, ) : ViewModel() { - private val sessionGraphs = mutableMapOf() + private val sessionGraphs = mutableMapOf() - fun graphFor(userId: UserId): AppSessionViewModelGraph = + fun retainedGraphFor(userId: UserId): RetainedSessionGraph = sessionGraphs.getOrPut(userId) { appLogger.i("WireActivity creating lifecycle-retained session graph for $userId") - appGraph.createSessionViewModelGraph(userId) + RetainedSessionGraph(createSessionGraph(userId)) } + + fun invalidate(userId: UserId) { + sessionGraphs.remove(userId)?.also { + appLogger.i("WireActivity clearing lifecycle-retained session graph for $userId") + it.clear() + } + } + + override fun onCleared() { + sessionGraphs.values.forEach(RetainedSessionGraph::clear) + sessionGraphs.clear() + } +} + +internal class RetainedSessionGraph( + val graph: AppSessionViewModelGraph, + override val viewModelStore: ViewModelStore = ViewModelStore(), +) : ViewModelStoreOwner { + + fun clear() { + viewModelStore.clear() + graph.wireSessionImageLoader.shutdown() + } } internal data class WireActivityActiveGraphRequest( @@ -1420,6 +1477,11 @@ internal fun resolveWireActivityImageLoaderSessionGraph( 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) diff --git a/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt index 1e3198f2151..29cb655abd1 100644 --- a/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt @@ -37,6 +37,9 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream @OptIn(ExperimentalCoroutinesApi::class) class AccountSwitchUseCaseTest { @@ -86,9 +89,12 @@ class AccountSwitchUseCaseTest { } } - @Test - fun givenCurrentSessionIsInvalid_whenSwitchingToAccount_thenUpdateCurrentSessionAndDeleteTheOldOne() = testScope.runTest { - val currentAccount = ACCOUNT_INVALID_3 + @ParameterizedTest + @MethodSource("terminalLogoutReasons") + fun givenCurrentSessionIsInvalid_whenSwitchingToAccount_thenUpdateCurrentSessionAndDeleteTheOldOne( + logoutReason: LogoutReason + ) = testScope.runTest { + val currentAccount = AccountInfo.Invalid(ACCOUNT_INVALID_3.userId, logoutReason) val switchTo = ACCOUNT_VALID_2 val expectedResult = SwitchAccountResult.SwitchedToAnotherAccount @@ -151,6 +157,16 @@ class AccountSwitchUseCaseTest { val ACCOUNT_VALID_2 = AccountInfo.Valid(UserId("userId_valid_2", "domain_valid_2")) val ACCOUNT_INVALID_3 = AccountInfo.Invalid(UserId("userId_invalid_3", "domain_invalid_3"), LogoutReason.SELF_SOFT_LOGOUT) + + @JvmStatic + fun terminalLogoutReasons(): Stream = Stream.of( + LogoutReason.SELF_SOFT_LOGOUT, + LogoutReason.SELF_HARD_LOGOUT, + LogoutReason.MIGRATION_TO_CC_FAILED, + LogoutReason.DELETED_ACCOUNT, + LogoutReason.REMOVED_CLIENT, + LogoutReason.SESSION_EXPIRED, + ) } private class Arrangement(val testScope: TestScope) { diff --git a/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt new file mode 100644 index 00000000000..2659ffba970 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt @@ -0,0 +1,135 @@ +/* + * 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.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.util.ui.WireSessionImageLoader +import com.wire.kalium.logic.data.user.UserId +import io.mockk.every +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() { + 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] + + store.invalidate(USER_ID) + val newRetainedGraph = store.retainedGraphFor(USER_ID) + + assertNotSame(oldRetainedGraph, newRetainedGraph) + assertSame(newGraph, newRetainedGraph.graph) + assertTrue(oldViewModel.wasCleared) + verify(exactly = 1) { oldImageLoader.shutdown() } + } + + @Test + fun givenSessionGraphIsStillValid_whenRequestedAgain_thenGraphIsReused() { + val graph = graphWith(mockk(relaxed = true)) + val store = SessionGraphStoreViewModel { graph } + + val first = store.retainedGraphFor(USER_ID) + val second = store.retainedGraphFor(USER_ID) + + assertSame(first, second) + } + + @Test + fun givenSameUserIsRemovedAndLogsInRepeatedly_whenGraphsAreInvalidated_thenEveryLoginGetsAFreshGraph() { + val imageLoaders = List(RELOGIN_COUNT) { mockk(relaxed = true) } + val graphs = ArrayDeque(imageLoaders.map(::graphWith)) + val store = SessionGraphStoreViewModel { graphs.removeFirst() } + + val retainedGraphs = buildList { + repeat(RELOGIN_COUNT) { index -> + add(store.retainedGraphFor(USER_ID)) + if (index < RELOGIN_COUNT - 1) { + store.invalidate(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() } + } + verify(exactly = 0) { imageLoaders.last().shutdown() } + } + + @Test + fun givenTwoRetainedUsers_whenRemovedUserIsInvalidated_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 store = SessionGraphStoreViewModel { userId -> graphsByUser.getValue(userId) } + val removedUsersGraph = store.retainedGraphFor(USER_ID) + val otherUsersGraph = store.retainedGraphFor(OTHER_USER_ID) + + store.invalidate(USER_ID) + + assertSame(otherUsersGraph, store.retainedGraphFor(OTHER_USER_ID)) + assertNotSame(removedUsersGraph, store.retainedGraphFor(USER_ID)) + verify(exactly = 1) { removedUserImageLoader.shutdown() } + verify(exactly = 0) { otherUserImageLoader.shutdown() } + } + + private fun graphWith(imageLoader: WireSessionImageLoader) = mockk { + 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") + val OTHER_USER_ID = UserId("other-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 index 8f5f482b364..4ab66892820 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityActiveGraphResolverTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityActiveGraphResolverTest.kt @@ -380,6 +380,36 @@ class WireActivityActiveGraphResolverTest { 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, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt index 339957fd6b3..30757b53458 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt @@ -122,6 +122,11 @@ class WireSessionImageLoader( return painter } + /** Cancels pending image requests and releases resources tied to this user session. */ + fun shutdown() { + coilImageLoader.shutdown() + } + class Factory( val context: Context, private val getAvatarAsset: GetAvatarAssetUseCase, diff --git a/core/ui-common/src/test/kotlin/com/wire/android/util/ui/WireSessionImageLoaderTest.kt b/core/ui-common/src/test/kotlin/com/wire/android/util/ui/WireSessionImageLoaderTest.kt new file mode 100644 index 00000000000..5b9f4f895fe --- /dev/null +++ b/core/ui-common/src/test/kotlin/com/wire/android/util/ui/WireSessionImageLoaderTest.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. + * + * 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.util.ui + +import coil3.ImageLoader +import com.wire.kalium.network.NetworkStateObserver +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class WireSessionImageLoaderTest { + + @Test + fun givenSessionImageLoader_whenShutdown_thenPendingImageRequestsAreCancelled() { + val coilImageLoader = mockk(relaxed = true) + val imageLoader = WireSessionImageLoader( + coilImageLoader = coilImageLoader, + networkStateObserver = mockk(relaxed = true) + ) + + imageLoader.shutdown() + + verify(exactly = 1) { coilImageLoader.shutdown() } + } +} diff --git a/kalium b/kalium index c5da75f69e1..fa1b990560e 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit c5da75f69e19938580ecc468a62d11726e96b4e1 +Subproject commit fa1b990560e70fa92b279ebabfe797262470b39d 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 2b85096e29e..7b462b5cc87 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 @@ -124,9 +124,10 @@ class MultiAccountSessionScope : BaseUiTest() { @TestCaseId("TC-11261") @Category("regression", "RC", "multiAccountSessionScope") @Test - fun givenSingleLoggedInAccount_whenCurrentClientIsRemovedRemotely_thenLoginScreenOpens() { + fun givenSingleLoggedInAccount_whenCurrentClientIsRemovedAndUserLogsInAgain_thenMlsSessionIsRestored() { step("Prepare staging users") { prepareTeamUsers(teamName = "SessionScopeMetroRemovedSingle") + testServiceHelper.addDevice("user2Name", null, "Device1") } step("Login the first account") { @@ -144,6 +145,41 @@ class MultiAccountSessionScope : BaseUiTest() { } pages.registrationPage.assertAuthEntryVisible() } + + step("Log the removed user in again without restarting the app") { + loginUser(primaryUser, configureStagingBackend = false) + } + + 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", + POST_RELOGIN_MLS_MESSAGE, + "Device1", + "user1Name" + ) + pages.conversationViewPage.assertReceivedMessageIsVisibleInCurrentConversation(POST_RELOGIN_MLS_MESSAGE) + } + + step("Send 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", + conversationWithAlias = "user1Name", + message = POST_RELOGIN_REPLY, + ) + } } @TestCaseId("TC-11262") @@ -341,6 +377,8 @@ class MultiAccountSessionScope : BaseUiTest() { private companion object { const val EXISTING_CLIENTS_LIMIT = 7 + 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/testsSupport/src/main/service/TestServiceHelper.kt b/tests/testsSupport/src/main/service/TestServiceHelper.kt index 792a7f286c2..e766aa8aab9 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -505,6 +505,37 @@ class TestServiceHelper( ) } + fun assertMessageReceivedInPersonalMlsConversation( + receiverAlias: String, + deviceName: String, + conversationWithAlias: String, + message: String, + ) { + val receiver = toClientUser(receiverAlias) + val conversation = toConvoObjPersonal(receiver, conversationWithAlias) + val received = UiWaitUtils.retryUntilTimeout( + timeout = UiWaitUtils.MEDIUM_TIMEOUT, + pollingInterval = 1.seconds + ) { + runCatching { + testServiceClient.getMessageIdByText( + owner = receiver, + deviceName = deviceName, + convoId = conversation.qualifiedID.id, + convoDomain = conversation.qualifiedID.domain, + text = message, + ) + }.isSuccess + } + + if (!received) { + throw AssertionError( + "Test-service device '$deviceName' for '$receiverAlias' did not receive '$message' " + + "in its MLS conversation with '$conversationWithAlias'." + ) + } + } + fun userSendEphemeralMessageToConversation( senderAlias: String, msg: String, From 92d439840c4a2e2da8a22bb2727698cf9eb767e2 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:13:32 +0200 Subject: [PATCH 3/5] fix(compose): preserve session graph stability --- .../com/wire/android/ui/WireActivity.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) 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 213c905aba1..7211cdb49ea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -39,7 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -446,11 +446,13 @@ class WireActivity : BaseActivity() { isUserUiBlocked = isUserUiBlocked, sessionTransitionReason = sessionTransitionReason, ) - HandleRetainedSessionGraphInvalidation( - shouldInvalidate = shouldInvalidateRetainedSessionGraph, - lastSessionGraphContext = lastSessionGraphContext, - sessionGraphStore = sessionGraphStore, - ) + LaunchedEffect(shouldInvalidateRetainedSessionGraph) { + lastSessionGraphContext.value = invalidateRetainedSessionGraphIfNeeded( + shouldInvalidate = shouldInvalidateRetainedSessionGraph, + lastSessionGraphContext = lastSessionGraphContext.value, + sessionGraphStore = sessionGraphStore, + ) + } val graphContextWithRetainedImageLoader = graphContext?.copy( imageLoaderSessionGraph = resolveWireActivityImageLoaderSessionGraph( activeSessionGraph = graphContext.sessionGraph, @@ -499,18 +501,15 @@ class WireActivity : BaseActivity() { noSessionAuthenticationStartedWithoutSession = noSessionAuthenticationStartedWithoutSession, ) - @Composable - private fun HandleRetainedSessionGraphInvalidation( + private fun invalidateRetainedSessionGraphIfNeeded( shouldInvalidate: Boolean, - lastSessionGraphContext: MutableState, + lastSessionGraphContext: WireActivityGraphContext?, sessionGraphStore: SessionGraphStoreViewModel, - ) { - LaunchedEffect(shouldInvalidate) { - if (shouldInvalidate) { - lastSessionGraphContext.value?.sessionGraph?.currentAccount?.let(sessionGraphStore::invalidate) - lastSessionGraphContext.value = null - } - } + ): WireActivityGraphContext? { + if (!shouldInvalidate) return lastSessionGraphContext + + lastSessionGraphContext?.sessionGraph?.currentAccount?.let(sessionGraphStore::invalidate) + return null } private fun isNavigationAllowed(navigationCommand: NavigationCommand): Boolean { @@ -1356,6 +1355,7 @@ internal class SessionGraphStoreViewModel( } } +@Stable internal class RetainedSessionGraph( val graph: AppSessionViewModelGraph, override val viewModelStore: ViewModelStore = ViewModelStore(), From 5034f2d79f6e31b5c53b937e1ae9222766eef233 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:43:05 +0200 Subject: [PATCH 4/5] fix(navigation): rename state retrieval function for clarity --- .../kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt | 2 +- app/src/main/kotlin/com/wire/android/ui/WireActivity.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt index e37d2733b3f..825e307ba2e 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/WireActivityNavigatorTest.kt @@ -81,7 +81,7 @@ class WireActivityNavigatorTest { isAllowedToNavigate = { true }, ) currentController = navigator.navController - currentRoute = rememberWireActivityCurrentBackStackEntryState(navigator) + currentRoute = wireActivityCurrentBackStackEntryAsState(navigator) .value ?.destination ?.route 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 7211cdb49ea..179c6fb4a8d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -397,7 +397,7 @@ class WireActivity : BaseActivity() { finish = this@WireActivity::finish, isAllowedToNavigate = ::isNavigationAllowed ) - val currentBackStackEntryState = rememberWireActivityCurrentBackStackEntryState(navigator) + val currentBackStackEntryState = wireActivityCurrentBackStackEntryAsState(navigator) val currentBaseRoute = currentBackStackEntryState.value ?.destination ?.route @@ -1307,7 +1307,7 @@ internal fun observeAppLockUserId( }.filterNotNull() @Composable -internal fun rememberWireActivityCurrentBackStackEntryState( +internal fun wireActivityCurrentBackStackEntryAsState( navigator: Navigator, ): State = key(navigator.navController) { // collectAsState retains its previous value while changing flows, so reset its composition From 75b2ac19b2355a82fd53b6ea9ea5d96bbd4b2fbf Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:42:40 +0200 Subject: [PATCH 5/5] active graph invalidation and retrieval logic --- .../com/wire/android/ui/WireActivity.kt | 17 ++++++++++++--- .../ui/SessionGraphStoreViewModelTest.kt | 21 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) 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 179c6fb4a8d..b8d1eafa9cc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -508,7 +508,7 @@ class WireActivity : BaseActivity() { ): WireActivityGraphContext? { if (!shouldInvalidate) return lastSessionGraphContext - lastSessionGraphContext?.sessionGraph?.currentAccount?.let(sessionGraphStore::invalidate) + sessionGraphStore.invalidateActive() return null } @@ -1335,23 +1335,34 @@ internal class SessionGraphStoreViewModel( private val createSessionGraph: (UserId) -> AppSessionViewModelGraph, ) : ViewModel() { private val sessionGraphs = mutableMapOf() + private var activeUserId: UserId? = null - fun retainedGraphFor(userId: UserId): RetainedSessionGraph = - sessionGraphs.getOrPut(userId) { + 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 } } 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 2659ffba970..bf9b9a5e8f1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/SessionGraphStoreViewModelTest.kt @@ -58,6 +58,23 @@ class SessionGraphStoreViewModelTest { verify(exactly = 1) { oldImageLoader.shutdown() } } + @Test + fun givenActivityIsRecreated_whenActiveGraphIsInvalidated_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) + + store.invalidateActive() + val newRetainedGraph = store.retainedGraphFor(USER_ID) + + assertNotSame(oldRetainedGraph, newRetainedGraph) + assertSame(newGraph, newRetainedGraph.graph) + verify(exactly = 1) { oldImageLoader.shutdown() } + } + @Test fun givenSessionGraphIsStillValid_whenRequestedAgain_thenGraphIsReused() { val graph = graphWith(mockk(relaxed = true)) @@ -103,10 +120,10 @@ class SessionGraphStoreViewModelTest { OTHER_USER_ID to graphWith(otherUserImageLoader), ) val store = SessionGraphStoreViewModel { userId -> graphsByUser.getValue(userId) } - val removedUsersGraph = store.retainedGraphFor(USER_ID) val otherUsersGraph = store.retainedGraphFor(OTHER_USER_ID) + val removedUsersGraph = store.retainedGraphFor(USER_ID) - store.invalidate(USER_ID) + store.invalidateActive() assertSame(otherUsersGraph, store.retainedGraphFor(OTHER_USER_ID)) assertNotSame(removedUsersGraph, store.retainedGraphFor(USER_ID))