diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 094009f3d3a..c6b42e1d117 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -51,6 +52,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( private val appNavigationStateService: AppNavigationStateService, private val appCoroutineScope: CoroutineScope, private val matrixClient: MatrixClient, + private val activeRoomsHolder: ActiveRoomsHolder, roomComponentFactory: RoomComponentFactory, ) : BaseFlowNode( backstack = BackStack( @@ -85,6 +87,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( onCreate = { Timber.v("OnCreate => ${inputs.room.roomId}") appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) + activeRoomsHolder.addRoom(inputs.room) fetchRoomMembers() trackVisitedRoom() }, @@ -95,6 +98,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( }, onDestroy = { Timber.v("OnDestroy") + activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId) inputs.room.destroy() appNavigationStateService.onLeavingRoom(id) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinBaseRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt similarity index 70% rename from appnav/src/test/kotlin/io/element/android/appnav/JoinBaseRoomLoadedFlowNodeTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt index bb15ed6bb98..f8d4be1807b 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinBaseRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -24,16 +24,18 @@ import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.libraries.architecture.childNode import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class JoinBaseRoomLoadedFlowNodeTest { +class JoinedRoomLoadedFlowNodeTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @@ -100,6 +102,7 @@ class JoinBaseRoomLoadedFlowNodeTest { plugins: List, messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), coroutineScope: CoroutineScope, ) = JoinedRoomLoadedFlowNode( buildContext = BuildContext.root(savedStateMap = null), @@ -110,6 +113,7 @@ class JoinBaseRoomLoadedFlowNodeTest { appCoroutineScope = coroutineScope, roomComponentFactory = FakeRoomComponentFactory(), matrixClient = FakeMatrixClient(), + activeRoomsHolder = activeRoomsHolder, ) @Test @@ -154,4 +158,55 @@ class JoinBaseRoomLoadedFlowNodeTest { val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!! assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) } + + @Test + fun `the ActiveRoomsHolder will be updated with the loaded room on create`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})) + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages()) + val activeRoomsHolder = ActiveRoomsHolder() + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs), + messagesEntryPoint = fakeMessagesEntryPoint, + roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, + coroutineScope = this, + activeRoomsHolder = activeRoomsHolder, + ) + + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull() + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + // WHEN + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED) + // THEN + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull() + } + + @Test + fun `the ActiveRoomsHolder will be removed on destroy`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})) + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages()) + val activeRoomsHolder = ActiveRoomsHolder().apply { + addRoom(room) + } + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs), + messagesEntryPoint = fakeMessagesEntryPoint, + roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, + coroutineScope = this, + activeRoomsHolder = activeRoomsHolder, + ) + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED) + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull() + // WHEN + roomFlowNode.updateLifecycleState(Lifecycle.State.DESTROYED) + // THEN + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.DESTROYED) + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull() + } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 7156a3e6038..8550f06f3d2 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.services.analytics.api.ScreenTracker +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope @@ -62,6 +63,7 @@ class CallScreenPresenter @AssistedInject constructor( private val activeCallManager: ActiveCallManager, private val languageTagProvider: LanguageTagProvider, private val appForegroundStateService: AppForegroundStateService, + private val activeRoomsHolder: ActiveRoomsHolder, private val appCoroutineScope: CoroutineScope, ) : Presenter { @AssistedFactory @@ -241,8 +243,10 @@ class CallScreenPresenter @AssistedInject constructor( private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) { if (!notifiedCallStart) { - getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() } - ?.onSuccess { notifiedCallStart = true } + val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) + val sendCallNotificationResult = activeRoomForSession?.sendCallNotificationIfNeeded() + ?: getJoinedRoom(roomId)?.use { it.sendCallNotificationIfNeeded() } + sendCallNotificationResult?.onSuccess { notifiedCallStart = true } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt index 6f6bd9473c1..dd4de7abb53 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject @@ -24,6 +25,7 @@ class DefaultCallWidgetProvider @Inject constructor( private val matrixClientsProvider: MatrixClientProvider, private val appPreferencesStore: AppPreferencesStore, private val callWidgetSettingsProvider: CallWidgetSettingsProvider, + private val activeRoomsHolder: ActiveRoomsHolder, ) : CallWidgetProvider { override suspend fun getWidget( sessionId: SessionId, @@ -33,7 +35,9 @@ class DefaultCallWidgetProvider @Inject constructor( theme: String?, ): Result = runCatching { val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow() - val room = matrixClient.getJoinedRoom(roomId) ?: error("Room not found") + val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) + ?: matrixClient.getJoinedRoom(roomId) + ?: error("Room not found") val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 43a1e7ceba5..ef571e12b30 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.analytics.test.FakeScreenTracker +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.tests.testutils.WarmUpRule @@ -367,6 +368,7 @@ import kotlin.time.Duration.Companion.seconds activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), screenTracker: ScreenTracker = FakeScreenTracker(), appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ): CallScreenPresenter { val userAgentProvider = object : UserAgentProvider { override fun provide(): String { @@ -387,6 +389,7 @@ import kotlin.time.Duration.Companion.seconds languageTagProvider = FakeLanguageTagProvider("en-US"), appForegroundStateService = appForegroundStateService, appCoroutineScope = backgroundScope, + activeRoomsHolder = activeRoomsHolder, ) } } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index cd81533a47d..f9267eca7bb 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -15,11 +15,13 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import kotlinx.coroutines.test.runTest import org.junit.Test @@ -77,6 +79,23 @@ class DefaultCallWidgetProviderTest { assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull() } + @Test + fun `getWidget - reuses the active room if possible`() = runTest { + val client = FakeMatrixClient().apply { + // No room from the client + givenGetRoomResult(A_ROOM_ID, null) + } + val activeRoomsHolder = ActiveRoomsHolder().apply { + // A current active room with the same room id + addRoom(FakeJoinedRoom(baseRoom = FakeBaseRoom(roomId = A_ROOM_ID))) + } + val provider = createProvider( + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + activeRoomsHolder = activeRoomsHolder + ) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + @Test fun `getWidget - will use a custom base url if it exists`() = runTest { val room = FakeJoinedRoom( @@ -104,9 +123,11 @@ class DefaultCallWidgetProviderTest { matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ) = DefaultCallWidgetProvider( matrixClientsProvider = matrixClientProvider, appPreferencesStore = appPreferencesStore, callWidgetSettingsProvider = callWidgetSettingsProvider, + activeRoomsHolder = activeRoomsHolder, ) } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 8d34d55559a..397d0c7acb0 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { implementation(projects.features.roomlist.api) implementation(projects.services.analytics.api) implementation(projects.services.analytics.compose) + implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) implementation(libs.datetime) implementation(libs.coil.compose) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 8a081c0e417..5662e4fd46c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.PushService +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import javax.inject.Inject @@ -37,8 +38,11 @@ class DefaultClearCacheUseCase @Inject constructor( private val ftueService: FtueService, private val pushService: PushService, private val seenInvitesStore: SeenInvitesStore, + private val activeRoomsHolder: ActiveRoomsHolder, ) : ClearCacheUseCase { override suspend fun invoke() = withContext(coroutineDispatchers.io) { + // Active rooms should be disposed of before clearing the cache + activeRoomsHolder.clear(matrixClient.sessionId) // Clear Matrix cache matrixClient.clearCache() // Clear Coil cache diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt index 401477d5fca..ab391bfc625 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt @@ -15,8 +15,11 @@ import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.push.test.FakePushService +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers @@ -31,8 +34,10 @@ import org.robolectric.RobolectricTestRunner class DefaultClearCacheUseCaseTest { @Test fun `execute clear cache should do all the expected tasks`() = runTest { + val activeRoomsHolder = ActiveRoomsHolder().apply { addRoom(FakeJoinedRoom()) } val clearCacheLambda = lambdaRecorder { } val matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, clearCacheLambda = clearCacheLambda, ) val defaultCacheService = DefaultCacheService() @@ -55,6 +60,7 @@ class DefaultClearCacheUseCaseTest { ftueService = ftueService, pushService = pushService, seenInvitesStore = seenInvitesStore, + activeRoomsHolder = activeRoomsHolder, ) defaultCacheService.clearedCacheEventFlow.test { sut.invoke() @@ -64,6 +70,7 @@ class DefaultClearCacheUseCaseTest { .with(value(matrixClient.sessionId), value(false)) assertThat(awaitItem()).isEqualTo(matrixClient.sessionId) assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull() } } } diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index ae0cad80dff..c059a249820 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(projects.libraries.roomselect.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) + implementation(projects.services.appnavstate.api) api(libs.statemachine) api(projects.features.share.api) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 08e9bdc1ff4..1c75daa828e 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -20,9 +20,11 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -33,6 +35,7 @@ class SharePresenter @AssistedInject constructor( private val matrixClient: MatrixClient, private val mediaPreProcessor: MediaPreProcessor, private val sessionPreferencesStore: SessionPreferencesStore, + private val activeRoomsHolder: ActiveRoomsHolder, ) : Presenter { @AssistedFactory interface Factory { @@ -59,6 +62,12 @@ class SharePresenter @AssistedInject constructor( ) } + private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? { + return activeRoomsHolder.getActiveRoom(matrixClient.sessionId) + ?.takeIf { it.roomId == roomId } + ?: matrixClient.getJoinedRoom(roomId) + } + private fun CoroutineScope.share( intent: Intent, roomIds: List, @@ -72,7 +81,7 @@ class SharePresenter @AssistedInject constructor( } else { roomIds .map { roomId -> - val room = matrixClient.getJoinedRoom(roomId) ?: return@map false + val room = getJoinedRoom(roomId) ?: return@map false val mediaSender = MediaSender( preProcessor = mediaPreProcessor, room = room, @@ -86,7 +95,11 @@ class SharePresenter @AssistedInject constructor( ).isSuccess } .all { it } - .also { room.destroy() } + .also { + if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { + room.destroy() + } + } } .all { it } } @@ -94,7 +107,7 @@ class SharePresenter @AssistedInject constructor( onPlainText = { text -> roomIds .map { roomId -> - matrixClient.getJoinedRoom(roomId)?.liveTimeline?.sendMessage( + getJoinedRoom(roomId)?.liveTimeline?.sendMessage( body = text, htmlBody = null, intentionalMentions = emptyList(), diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 3504390f680..07424aa384d 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.test.TestScope @@ -163,7 +164,8 @@ class SharePresenterTest { intent: Intent = Intent(), shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), matrixClient: MatrixClient = FakeMatrixClient(), - mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor() + mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ): SharePresenter { return SharePresenter( intent = intent, @@ -171,7 +173,8 @@ class SharePresenterTest { shareIntentHandler = shareIntentHandler, matrixClient = matrixClient, mediaPreProcessor = mediaPreProcessor, - InMemorySessionPreferencesStore(), + sessionPreferencesStore = InMemorySessionPreferencesStore(), + activeRoomsHolder = activeRoomsHolder, ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index d01675bb295..79e3a1e0d23 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -416,7 +416,6 @@ class JoinedRustRoom( RustWidgetDriver( widgetSettings = widgetSettings, room = innerRoom, - joinedRustRoom = this, widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider { override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities { return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt index b723ab194ba..c80313f5da6 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.impl.widget import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings -import io.element.android.libraries.matrix.impl.room.JoinedRustRoom import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -25,7 +24,6 @@ import kotlin.coroutines.coroutineContext class RustWidgetDriver( widgetSettings: MatrixWidgetSettings, private val room: Room, - private val joinedRustRoom: JoinedRustRoom, private val widgetCapabilitiesProvider: WidgetCapabilitiesProvider, ) : MatrixWidgetDriver { // It's important to have extra capacity here to make sure we don't drop any messages @@ -71,6 +69,5 @@ class RustWidgetDriver( override fun close() { receiveMessageJob?.cancel() driverAndHandle.driver.close() - joinedRustRoom.destroy() } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt index 9c899c9dfd0..f597fc7e393 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor( private val onNotifiableEventReceived: OnNotifiableEventReceived, private val stringProvider: StringProvider, private val replyMessageExtractor: ReplyMessageExtractor, + private val activeRoomsHolder: ActiveRoomsHolder, ) { fun onReceive(intent: Intent) { val sessionId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_SESSION_ID)?.let(::SessionId) ?: return @@ -117,13 +119,15 @@ class NotificationBroadcastReceiverHandler @Inject constructor( return@launch } val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch - client.getJoinedRoom(roomId)?.let { room -> + val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) ?: client.getJoinedRoom(roomId) + + room?.let { sendMatrixEvent( sessionId = sessionId, roomId = roomId, replyToEventId = replyToEventId, threadId = threadId, - room = room, + room = it, message = message, ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt index 4c5eee260b7..23cad2aba95 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -30,28 +31,43 @@ class SyncOnNotifiableEvent @Inject constructor( private val featureFlagService: FeatureFlagService, private val appForegroundStateService: AppForegroundStateService, private val dispatchers: CoroutineDispatchers, + private val activeRoomsHolder: ActiveRoomsHolder, ) { suspend operator fun invoke(notifiableEvent: NotifiableEvent) = withContext(dispatchers.io) { val isRingingCallEvent = notifiableEvent is NotifiableRingingCallEvent if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush) && !isRingingCallEvent) { return@withContext } - val client = matrixClientProvider.getOrRestore(notifiableEvent.sessionId).getOrNull() ?: return@withContext - client.getJoinedRoom(notifiableEvent.roomId)?.use { room -> - room.subscribeToSync() + val activeRoom = activeRoomsHolder.getActiveRoomMatching(notifiableEvent.sessionId, notifiableEvent.roomId) - // If the app is in foreground, sync is already running, so we just add the subscription above. - if (!appForegroundStateService.isInForeground.value) { - if (isRingingCallEvent) { - room.waitsUntilUserIsInTheCall(timeout = 60.seconds) - } else { - try { - appForegroundStateService.updateIsSyncingNotificationEvent(true) - room.waitsUntilEventIsKnown(eventId = notifiableEvent.eventId, timeout = 10.seconds) - } finally { - appForegroundStateService.updateIsSyncingNotificationEvent(false) - } + if (activeRoom != null) { + // If the room is already active, we can use it directly + activeRoom.subscribeToSyncAndWait(notifiableEvent, isRingingCallEvent) + } else { + // Otherwise, we need to get the room from the matrix client + val room = matrixClientProvider + .getOrRestore(notifiableEvent.sessionId) + .mapCatching { it.getJoinedRoom(notifiableEvent.roomId) } + .getOrNull() + + room?.use { it.subscribeToSyncAndWait(notifiableEvent, isRingingCallEvent) } + } + } + + private suspend fun JoinedRoom.subscribeToSyncAndWait(notifiableEvent: NotifiableEvent, isRingingCallEvent: Boolean) { + subscribeToSync() + + // If the app is in foreground, sync is already running, so we just add the subscription above. + if (!appForegroundStateService.isInForeground.value) { + if (isRingingCallEvent) { + waitsUntilUserIsInTheCall(timeout = 60.seconds) + } else { + try { + appForegroundStateService.updateIsSyncingNotificationEvent(true) + waitsUntilEventIsKnown(eventId = notifiableEvent.eventId, timeout = 10.seconds) + } finally { + appForegroundStateService.updateIsSyncingNotificationEvent(false) } } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt index 8a4f5352163..f86bd5fa701 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.test.strings.FakeStringProvider @@ -477,6 +478,7 @@ class NotificationBroadcastReceiverHandlerTest { onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(), stringProvider: StringProvider = FakeStringProvider(), replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ): NotificationBroadcastReceiverHandler { return NotificationBroadcastReceiverHandler( appCoroutineScope = this, @@ -494,6 +496,7 @@ class NotificationBroadcastReceiverHandlerTest { onNotifiableEventReceived = onNotifiableEventReceived, stringProvider = stringProvider, replyMessageExtractor = replyMessageExtractor, + activeRoomsHolder = activeRoomsHolder, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt index 8b3907be0b4..9209eb4aebc 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -199,7 +200,8 @@ class SyncOnNotifiableEventTest { isSyncOnPushEnabled: Boolean = true, appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService( initialForegroundValue = true, - ) + ), + activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), ): SyncOnNotifiableEvent { val featureFlagService = FakeFeatureFlagService( initialState = mapOf( @@ -212,6 +214,7 @@ class SyncOnNotifiableEventTest { featureFlagService = featureFlagService, appForegroundStateService = appForegroundStateService, dispatchers = testCoroutineDispatchers(), + activeRoomsHolder = activeRoomsHolder, ) } } diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt new file mode 100644 index 00000000000..6a9cb9f0583 --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.services.appnavstate.api + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +/** + * Holds the active rooms for a given session so they can be reused instead of instantiating new ones. + */ +@SingleIn(AppScope::class) +class ActiveRoomsHolder @Inject constructor() { + private val rooms = ConcurrentHashMap>() + + /** + * Adds a new held room for the given sessionId. + */ + fun addRoom(room: JoinedRoom) { + val roomsForSessionId = rooms.getOrPut(key = room.sessionId, defaultValue = { mutableSetOf() }) + if (roomsForSessionId.none { it.roomId == room.roomId }) { + // We don't want to add the same room multiple times + roomsForSessionId.add(room) + } + } + + /** + * Returns the last room added for the given [sessionId] or null if no room was added. + */ + fun getActiveRoom(sessionId: SessionId): JoinedRoom? { + return rooms[sessionId]?.lastOrNull() + } + + /** + * Returns an active room associated to the given [sessionId], with the given [roomId], or null if none match. + */ + fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom? { + return rooms[sessionId]?.find { it.roomId == roomId } + } + + /** + * Removes any room matching the provided [sessionId] and [roomId]. + * + * @return true if a room was removed, false otherwise. + */ + fun removeRoom(sessionId: SessionId, roomId: RoomId): Boolean { + val roomsForSessionId = rooms[sessionId] ?: return false + return roomsForSessionId.removeIf { it.roomId == roomId } + } + + /** + * Clears all the rooms for the given sessionId. + */ + fun clear(sessionId: SessionId) { + val activeRooms = rooms.remove(sessionId) ?: return + for (room in activeRooms) { + // Destroy the room to reset the live timelines + room.destroy() + } + } +}