Merge remote-tracking branch 'upstream/develop' into feature/scroll-to-unread-messages

Resolved conflicts:
- libraries/matrix/impl/.../fakes/FakeFfiSpaceRoomList.kt: took upstream's
  direct return for rooms() — the simulateLongTask wrapper is for IO-style
  operations like paginate/reset, not a quick read.
- libraries/ui-strings/src/main/res/values/temporary.xml: kept the file with
  only the live strings (action_mark_as_read, a11y_jump_to_unread_messages).
  Dropped common_black (upstream moved theme strings to module-local R.string)
  and a11y_jump_to_unread_messages_count (badge count was removed earlier).
- RoomInfo.kt and its fixtures/tests: took upstream's version since both
  branches independently added fullyReadEventId via the same SDK bump
  (matrix-rust-components-kotlin 26.05.20).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-05-21 11:26:14 -07:00
369 changed files with 4556 additions and 2075 deletions
@@ -83,8 +83,8 @@ class RoomListDataSource(
val loadingState = roomList.loadingState
fun launchIn(coroutineScope: CoroutineScope) {
roomList
fun launchIn(coroutineScope: CoroutineScope): Job {
return roomList
.summaries
.onEach { roomSummaries ->
replaceWith(roomSummaries)
@@ -212,6 +212,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
diffCacheUpdater.updateWith(roomSummaries)
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}
@@ -5,8 +5,8 @@
<string name="banner_battery_optimization_title_android">"Sa ei näe kõiki teavitusi?"</string>
<string name="banner_new_sound_message">"Sinu nutiseadme teavituste heli on uuenenud - see on nüüd selgem, kiirem ja vähem häiriv."</string>
<string name="banner_new_sound_title">"Oleme sinu helisid värskendanud"</string>
<string name="banner_set_up_recovery_content">"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."</string>
<string name="banner_set_up_recovery_submit">"Seadista andmete taastamine"</string>
<string name="banner_set_up_recovery_content">"Sinu vestlused on automaatselt varundatud kasutades läbivat krüptimist. Kui peaksid kaotama ligipääsu kõikidele oma seadmetele, siis selle varukoopia taastamiseks ja oma digitaalse identiteedi säilitamiseks, on vaja taastevõtit."</string>
<string name="banner_set_up_recovery_submit">"Seadista taastevõti"</string>
<string name="banner_set_up_recovery_title">"Varunda oma vestlused"</string>
<string name="confirm_recovery_key_banner_message">"Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Sisesta oma taastevõti"</string>
@@ -50,6 +50,7 @@ Nu aveți mesaje necitite!"</string>
<string name="screen_roomlist_mark_as_read">"Marcați ca citită"</string>
<string name="screen_roomlist_mark_as_unread">"Marcați ca necitită"</string>
<string name="screen_roomlist_tombstoned_room_description">"Această cameră a fost modernizată."</string>
<string name="screen_roomlist_your_spaces">"Spațiile dumneavoastră"</string>
<string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate."</string>
<string name="session_verification_banner_title">"Verificați că sunteți dumneavoastră"</string>
</resources>
@@ -14,6 +14,9 @@ import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
@@ -100,11 +103,169 @@ class RoomListDataSourceTest {
}
}
/**
* Tracking issue #4182: rooms duplicated in the room list around midnight.
*
* If the SDK ever leaks a list containing the same roomId twice (the suspected cause of #4182),
* the UI mapper's `distinctBy` safety net in [RoomListDataSource.buildAndEmitAllRooms] must
* remove the duplicate AND `analyticsService.trackError` must fire so the team can root-cause
* it via Sentry.
*/
@Test
fun `when SDK summaries source contains duplicate roomIds, UI layer dedupes and reports trackError`() = runTest {
val analyticsService = FakeAnalyticsService()
val duplicatedSummaries = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(roomId = A_ROOM_ID_2),
)
val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(duplicatedSummaries))
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
).apply {
postState(RoomListService.State.Running)
}
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
analyticsService = analyticsService,
)
roomListDataSource.roomSummariesFlow.test {
roomListDataSource.launchIn(backgroundScope)
val list = awaitItem()
assertThat(list.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
assertThat(analyticsService.trackedErrors).hasSize(1)
}
}
/**
* Tracking issue #4182.
*
* Targeted scenario: a `DateChanged` tick fires after an initial SDK emit, then a follow-up
* SDK emit lands (mimicking "midnight, then a new message arrives"). Even though the diffCache
* is bypassed during the rebuild (`useCache = false`), the final state must contain each
* roomId exactly once and trackError must not fire on a happy path.
*/
@Test
fun `interleaved date change and SDK update with overlapping content does not produce duplicates`() = runTest {
val analyticsService = FakeAnalyticsService()
val summariesFlow = MutableStateFlow(
listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(roomId = A_ROOM_ID_2),
)
)
val roomList = FakeDynamicRoomList(summaries = summariesFlow)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
).apply {
postState(RoomListService.State.Running)
}
val dateTimeObserver = FakeDateTimeObserver()
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
dateTimeObserver = dateTimeObserver,
analyticsService = analyticsService,
)
roomListDataSource.roomSummariesFlow.test {
roomListDataSource.launchIn(backgroundScope)
val initial = awaitItem()
assertThat(initial.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
// Midnight ticks while the cache holds [A_ROOM_ID, A_ROOM_ID_2]
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
val afterMidnight = awaitItem()
assertThat(afterMidnight.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
// A new message bumps A_ROOM_ID — different unread count makes the StateFlow see this
// as a new value
summariesFlow.value = listOf(
aRoomSummary(roomId = A_ROOM_ID, numUnreadMessages = 1),
aRoomSummary(roomId = A_ROOM_ID_2),
)
val afterMessage = awaitItem()
assertThat(afterMessage.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
assertThat(afterMessage.map { it.roomId }.toSet()).hasSize(afterMessage.size)
// Second midnight rebuild after the new message
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
val afterSecondMidnight = awaitItem()
assertThat(afterSecondMidnight.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
assertThat(afterSecondMidnight.map { it.roomId }.toSet()).hasSize(afterSecondMidnight.size)
assertThat(analyticsService.trackedErrors).isEmpty()
}
}
@Test
fun `regression test for race with DateTimeObserver and new items`() = runTest {
val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))))
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
).apply {
postState(RoomListService.State.Running)
}
val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today"
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
),
dateTimeObserver = dateTimeObserver,
)
roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes
val job = roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
val initialRoomList = awaitItem()
assertThat(initialRoomList).hasSize(2)
assertThat(initialRoomList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(initialRoomList[0].timestamp).isEqualTo(dateFormatterResult)
assertThat(initialRoomList[1].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(initialRoomList[1].timestamp).isEqualTo(dateFormatterResult)
// Stop processing room list updates so we can force a race condition with the date time observer updates
job.cancel()
// Trigger a date change and a new item at the same time
dateFormatterResult = "Yesterday"
roomList.summaries.tryEmit(listOf(aRoomSummary(roomId = A_ROOM_ID), aRoomSummary(roomId = A_ROOM_ID_3), aRoomSummary(roomId = A_ROOM_ID_2)))
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
// The race condition would have caused the cache indices to be corrupted and only 2 items would be emitted
val rebuiltRoomList = awaitItem()
assertThat(rebuiltRoomList).hasSize(3)
assertThat(rebuiltRoomList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(rebuiltRoomList[0].timestamp).isEqualTo(dateFormatterResult)
assertThat(rebuiltRoomList[1].roomId).isEqualTo(A_ROOM_ID_3)
assertThat(rebuiltRoomList[1].timestamp).isEqualTo(dateFormatterResult)
assertThat(rebuiltRoomList[2].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(rebuiltRoomList[2].timestamp).isEqualTo(dateFormatterResult)
// Restart processing room list updates
roomListDataSource.launchIn(backgroundScope)
// Check there is a new list and it's not the same as the previous one
val newRoomList = awaitItem()
assertThat(newRoomList).hasSize(3)
assertThat(newRoomList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(newRoomList[0].timestamp).isEqualTo(dateFormatterResult)
assertThat(newRoomList[1].roomId).isEqualTo(A_ROOM_ID_3)
assertThat(newRoomList[1].timestamp).isEqualTo(dateFormatterResult)
assertThat(newRoomList[2].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(newRoomList[2].timestamp).isEqualTo(dateFormatterResult)
}
}
private fun TestScope.createRoomListDataSource(
roomListService: FakeRoomListService = FakeRoomListService(),
roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = RoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
@@ -112,6 +273,6 @@ class RoomListDataSourceTest {
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
analyticsService = FakeAnalyticsService(),
analyticsService = analyticsService,
)
}