Add ActiveRoomsHolder to manage the active rooms for a session (#4758)

This commit is contained in:
Jorge Martin Espinosa
2025-05-26 13:03:55 +02:00
committed by GitHub
parent b39100ef6f
commit c67089edf7
19 changed files with 240 additions and 30 deletions

View File

@@ -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<JoinedRoomLoadedFlowNode.NavTarget>(
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)
}

View File

@@ -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<Plugin>,
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()
}
}

View File

@@ -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<CallScreenState> {
@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 }
}
}

View File

@@ -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<CallWidgetProvider.GetWidgetResult> = 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

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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<Unit> { }
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()
}
}
}

View File

@@ -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)

View File

@@ -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<ShareState> {
@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<RoomId>,
@@ -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(),

View File

@@ -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,
)
}
}

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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<SessionId, MutableSet<JoinedRoom>>()
/**
* 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].
*/
fun removeRoom(sessionId: SessionId, roomId: RoomId) {
val roomsForSessionId = rooms[sessionId] ?: 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()
}
}
}