Skip to content

Commit

Permalink
Refresh room summaries when date or time changes in the device (#3683)
Browse files Browse the repository at this point in the history
* Add `DateTimeObserver` to rebuild the room summary data when the date/time changes.

* Add time changed action too, to trigger when the user manually changes date/time

* Fix timezone issue by adding `TimezoneProvider`, fix tests

* Create test for `DateTimeObserver` usage in `RoomListDataSource`

* Create aRoomListRoomSummaryFactory function.

* Improve test by faking the lastMessageTimestampFormatter

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
  • Loading branch information
jmartinesp and bmarty authored Oct 16, 2024
1 parent 0562b2f commit 2e9dce3
Show file tree
Hide file tree
Showing 16 changed files with 262 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner
Expand Down Expand Up @@ -93,7 +92,6 @@ class RoomListPresenter @Inject constructor(
private val logoutPresenter: Presenter<DirectLogoutState>,
) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService()
private val syncService: SyncService = client.syncService()

@Composable
override fun present(): RoomListState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package io.element.android.features.roomlist.impl.datasource
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
Expand All @@ -36,9 +37,11 @@ class RoomListDataSource @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val notificationSettingsService: NotificationSettingsService,
private val appScope: CoroutineScope,
private val dateTimeObserver: DateTimeObserver,
) {
init {
observeNotificationSettings()
observeDateTimeChanges()
}

private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
Expand Down Expand Up @@ -77,16 +80,31 @@ class RoomListDataSource @Inject constructor(
.launchIn(appScope)
}

private fun observeDateTimeChanges() {
dateTimeObserver.changes
.onEach { event ->
when (event) {
is DateTimeObserver.Event.TimeZoneChanged -> rebuildAllRoomSummaries()
is DateTimeObserver.Event.DateChanged -> rebuildAllRoomSummaries()
}
}
.launchIn(appScope)
}

private suspend fun replaceWith(roomSummaries: List<RoomSummary>) = withContext(coroutineDispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(roomSummaries)
buildAndEmitAllRooms(roomSummaries)
}
}

private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>) {
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
diffCache.get(index) ?: buildAndCacheItem(roomSummaries, index)
if (useCache) {
diffCache.get(index) ?: buildAndCacheItem(roomSummaries, index)
} else {
buildAndCacheItem(roomSummaries, index)
}
}
_allRooms.emit(roomListRoomSummaries.toImmutableList())
}
Expand All @@ -96,4 +114,12 @@ class RoomListDataSource @Inject constructor(
diffCache[index] = roomListSummary
return roomListSummary
}

private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
roomListService.allRooms.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
Expand Down Expand Up @@ -83,6 +84,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
Expand Down Expand Up @@ -649,13 +651,14 @@ class RoomListPresenterTest {
leaveRoomPresenter = { leaveRoomState },
roomListDataSource = RoomListDataSource(
roomListService = client.roomListService,
roomListRoomSummaryFactory = RoomListRoomSummaryFactory(
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
roomLastMessageFormatter = roomLastMessageFormatter,
),
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService(),
appScope = backgroundScope
appScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
),
featureFlagService = featureFlagService,
indicatorService = DefaultIndicatorService(
Expand All @@ -672,3 +675,11 @@ class RoomListPresenterTest {
logoutPresenter = { aDirectLogoutState() },
)
}

class FakeDateTimeObserver : DateTimeObserver {
override val changes = MutableSharedFlow<DateTimeObserver.Event>(extraBufferCapacity = 1)

fun given(event: DateTimeObserver.Event) {
changes.tryEmit(event)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.features.roomlist.impl.datasource

import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
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.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant

class RoomListDataSourceTest {
@Test
fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest {
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
val lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter()
lastMessageTimestampFormatter.givenFormat("Today")
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
),
dateTimeObserver = dateTimeObserver,
)

roomListDataSource.allRooms.test {
// Observe room list items changes
roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
val initialRoomList = awaitItem()
assertThat(initialRoomList).isNotEmpty()
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
lastMessageTimestampFormatter.givenFormat("Yesterday")
// Trigger a date change
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
// Check there is a new list and it's not the same as the previous one
val newRoomList = awaitItem()
assertThat(newRoomList).isNotSameInstanceAs(initialRoomList)
assertThat(newRoomList.first().timestamp).isEqualTo("Yesterday")
}
}

@Test
fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest {
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
val lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter()
lastMessageTimestampFormatter.givenFormat("Today")
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
),
dateTimeObserver = dateTimeObserver,
)
roomListDataSource.allRooms.test {
// Observe room list items changes
roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
val initialRoomList = awaitItem()
assertThat(initialRoomList).isNotEmpty()
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
lastMessageTimestampFormatter.givenFormat("Yesterday")
// Trigger a timezone change
dateTimeObserver.given(DateTimeObserver.Event.TimeZoneChanged)
// Check there is a new list and it's not the same as the previous one
val newRoomList = awaitItem()
assertThat(newRoomList).isNotSameInstanceAs(initialRoomList)
assertThat(newRoomList.first().timestamp).isEqualTo("Yesterday")
}
}

private fun TestScope.createRoomListDataSource(
roomListService: FakeRoomListService = FakeRoomListService(),
roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(),
) = RoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = notificationSettingsService,
appScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.features.roomlist.impl.datasource

import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter

fun aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter: LastMessageTimestampFormatter = LastMessageTimestampFormatter { _ -> "Today" },
roomLastMessageFormatter: RoomLastMessageFormatter = RoomLastMessageFormatter { _, _ -> "Hey" }
) = RoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
roomLastMessageFormatter = roomLastMessageFormatter
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
Expand Down Expand Up @@ -142,7 +142,7 @@ fun TestScope.createRoomListSearchPresenter(
return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource(
roomListService = roomListService,
roomSummaryFactory = RoomListRoomSummaryFactory(
roomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/

package io.element.android.libraries.androidutils.system

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.system.DateTimeObserver.Event
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.time.Instant
import javax.inject.Inject

interface DateTimeObserver {
val changes: Flow<Event>

sealed interface Event {
data object TimeZoneChanged : Event
data class DateChanged(val previous: Instant, val new: Instant) : Event
}
}

@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultDateTimeObserver @Inject constructor(
@ApplicationContext context: Context
) : DateTimeObserver {
private val dateTimeReceiver = object : BroadcastReceiver() {
private var lastTime = Instant.now()

override fun onReceive(context: Context, intent: Intent) {
val newDate = Instant.now()
when (intent.action) {
Intent.ACTION_TIMEZONE_CHANGED -> changes.tryEmit(Event.TimeZoneChanged)
Intent.ACTION_DATE_CHANGED -> changes.tryEmit(Event.DateChanged(lastTime, newDate))
Intent.ACTION_TIME_CHANGED -> changes.tryEmit(Event.DateChanged(lastTime, newDate))
}
lastTime = newDate
}
}

override val changes = MutableSharedFlow<Event>(extraBufferCapacity = 10)

init {
context.registerReceiver(dateTimeReceiver, IntentFilter().apply {
addAction(Intent.ACTION_TIMEZONE_CHANGED)
addAction(Intent.ACTION_DATE_CHANGED)
addAction(Intent.ACTION_TIME_CHANGED)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@

package io.element.android.libraries.dateformatter.api

interface LastMessageTimestampFormatter {
fun interface LastMessageTimestampFormatter {
fun format(timestamp: Long?): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import android.text.format.DateFormat
import android.text.format.DateUtils
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
Expand All @@ -25,7 +24,7 @@ import kotlin.math.absoluteValue
class DateFormatters @Inject constructor(
private val locale: Locale,
private val clock: Clock,
private val timeZone: TimeZone,
private val timeZoneProvider: TimezoneProvider,
) {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
Expand Down Expand Up @@ -70,7 +69,7 @@ class DateFormatters @Inject constructor(
return if (period.years.absoluteValue >= 1) {
formatDateWithYear(dateToFormat)
} else if (useRelative && period.days.absoluteValue < 2 && period.months.absoluteValue < 1) {
getRelativeDay(dateToFormat.toInstant(timeZone).toEpochMilliseconds())
getRelativeDay(dateToFormat.toInstant(timeZoneProvider.provide()).toEpochMilliseconds())
} else {
formatDateWithMonth(dateToFormat)
}
Expand Down
Loading

0 comments on commit 2e9dce3

Please sign in to comment.